From be71767cb93c92da95ad6a7b3c5e90bf90ae98a5 Mon Sep 17 00:00:00 2001 From: Miguel Lopez Date: Mon, 13 Jul 2026 18:58:48 -0400 Subject: [PATCH 1/7] Improve editor performance and MCP controls --- .../Editor/UntermAgentWindow.cs | 125 +++++++---- .../Editor/UntermCodeEditorWindow.cs | 72 +++++-- .../dev.tnayuki.unterm/Editor/UntermMcp.cs | 44 ++-- .../Editor/UntermMcpSecurity.cs | 200 ++++++++++++++---- .../Editor/UntermMcpServer.cs | 41 +++- .../Editor/UntermSettingsProvider.cs | 52 ++++- .../dev.tnayuki.unterm/Editor/UntermWindow.cs | 84 ++++++-- .../Editor/UntermWindowTitle.cs | 31 +++ .../Editor/UntermWindowTitle.cs.meta | 11 + .../Tests/Editor/McpSecurityTests.cs | 111 +++++++--- 10 files changed, 597 insertions(+), 174 deletions(-) create mode 100644 Packages/dev.tnayuki.unterm/Editor/UntermWindowTitle.cs create mode 100644 Packages/dev.tnayuki.unterm/Editor/UntermWindowTitle.cs.meta diff --git a/Packages/dev.tnayuki.unterm/Editor/UntermAgentWindow.cs b/Packages/dev.tnayuki.unterm/Editor/UntermAgentWindow.cs index 27abe2c..5b79cf2 100644 --- a/Packages/dev.tnayuki.unterm/Editor/UntermAgentWindow.cs +++ b/Packages/dev.tnayuki.unterm/Editor/UntermAgentWindow.cs @@ -128,12 +128,16 @@ private HashSet BusyIds() // Transcript panel texture (zero-copy wrap of the native MTLTexture). private Texture2D _tex; - private IntPtr _externalTexPtr; + private readonly Dictionary _panelTextures = + new Dictionary(); + private int _panelTextureW, _panelTextureH; // Input strip texture (the native input box renders the field AND the // Send/Stop button into this one surface). private Texture2D _inputTex; - private IntPtr _inputExternalTexPtr; + private readonly Dictionary _inputTextures = + new Dictionary(); + private int _inputTextureW, _inputTextureH; private float _inputHeight = InputHeight; // logical px, grows with content private float _scroll; // physical px, 0 = latest @@ -166,7 +170,7 @@ public static void Open() // focused one so new windows don't stack exactly on top. var from = focusedWindow as UntermAgentWindow; var w = CreateWindow(); - w.titleContent = new GUIContent("Claude Code"); + w.titleContent = UntermWindowTitle.Create("Claude Code", UntermWindowTitle.AgentIcon, w.titleContent); w.minSize = new Vector2(320, 200); Vector2 size = from != null ? from.position.size : new Vector2(420f, 520f); @@ -371,7 +375,7 @@ private void OnEditorUpdate() { string agent = _native.AgentviewTitle(Vid); if (!string.IsNullOrEmpty(agent) && titleContent.text != agent) - titleContent = new GUIContent(agent); + titleContent = UntermWindowTitle.Create(agent, UntermWindowTitle.AgentIcon, titleContent); } // Drain any in-flight recent-sessions listing (for the picker dropdown). @@ -542,16 +546,7 @@ private void ApplyFonts() /// conversation persists; otherwise destroy the view and unload. private void Teardown(bool keepView) { - if (_tex != null) - { - DestroyImmediate(_tex); - _tex = null; - } - if (_inputTex != null) - { - DestroyImmediate(_inputTex); - _inputTex = null; - } + ClearRenderTextureCaches(); if (_imeBgTex != null) { DestroyImmediate(_imeBgTex); @@ -673,65 +668,100 @@ private void RenderView(bool measureInput = true) } } + private static System.Reflection.MethodInfo s_bgMethod; + private static bool s_bgLooked; + private static Color s_bgColor; + private static bool s_bgDark, s_bgValid; + private static Color GetEditorBackground() { - // Prefer Unity's exact themed background; fall back to standard grays. - var m = typeof(EditorGUIUtility).GetMethod( - "GetDefaultBackgroundColor", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); - if (m != null && m.ReturnType == typeof(Color)) - return (Color)m.Invoke(null, null); - - return EditorGUIUtility.isProSkin - ? (Color)new Color32(56, 56, 56, 255) - : (Color)new Color32(194, 194, 194, 255); + bool dark = EditorGUIUtility.isProSkin; + if (s_bgValid && s_bgDark == dark) return s_bgColor; + if (!s_bgLooked) + { + s_bgLooked = true; + var m = typeof(EditorGUIUtility).GetMethod( + "GetDefaultBackgroundColor", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + if (m != null && m.ReturnType == typeof(Color)) s_bgMethod = m; + } + s_bgColor = s_bgMethod != null + ? (Color)s_bgMethod.Invoke(null, null) + : dark + ? (Color)new Color32(56, 56, 56, 255) + : (Color)new Color32(194, 194, 194, 255); + s_bgDark = dark; + s_bgValid = true; + return s_bgColor; } // Wrap the native transcript texture directly — no CPU copy (zero-copy // only, like the terminal). The pointer alternates on Windows' double - // buffer, so re-wrap whenever it changes; null means the frame isn't ready - // yet (e.g. Windows D3D device not captured) — skip this tick. + // buffer. Cache one Unity wrapper per pointer so the alternating buffers do + // not allocate and destroy Texture2D objects every rendered frame. private void UploadPanel(int iw, int ih) { IntPtr texPtr = _native.AgentviewPanelTexture(Vid); if (texPtr == IntPtr.Zero) return; - if (_tex == null || _tex.width != iw || _tex.height != ih || _externalTexPtr != texPtr) + if (_panelTextureW != iw || _panelTextureH != ih) { - if (_tex != null) DestroyImmediate(_tex); - _tex = Texture2D.CreateExternalTexture( - iw, ih, TextureFormat.RGBA32, false, false, texPtr); - _tex.filterMode = FilterMode.Bilinear; - _tex.hideFlags = HideFlags.HideAndDontSave; - _externalTexPtr = texPtr; + ClearTextureCache(_panelTextures); + _panelTextureW = iw; + _panelTextureH = ih; } - else + if (!_panelTextures.TryGetValue(texPtr, out var tex) || tex == null) { - _tex.UpdateExternalTexture(texPtr); + tex = Texture2D.CreateExternalTexture( + iw, ih, TextureFormat.RGBA32, false, false, texPtr); + tex.filterMode = FilterMode.Bilinear; + tex.hideFlags = HideFlags.HideAndDontSave; + _panelTextures[texPtr] = tex; } + _tex = tex; } - // Wrap the native input strip texture (zero-copy only); re-wrap when the - // pointer changes (Windows double buffer), skip a null frame. + // Wrap the native input strip texture (zero-copy only), caching both sides + // of the Windows double buffer just like the transcript panel. private void UploadInput(int iw, int ih) { IntPtr texPtr = _native.AgentviewInputTexture(Vid); if (texPtr == IntPtr.Zero) return; - if (_inputTex == null || _inputTex.width != iw || _inputTex.height != ih - || _inputExternalTexPtr != texPtr) + if (_inputTextureW != iw || _inputTextureH != ih) + { + ClearTextureCache(_inputTextures); + _inputTextureW = iw; + _inputTextureH = ih; + } + if (!_inputTextures.TryGetValue(texPtr, out var tex) || tex == null) { - if (_inputTex != null) DestroyImmediate(_inputTex); - _inputTex = Texture2D.CreateExternalTexture( + tex = Texture2D.CreateExternalTexture( iw, ih, TextureFormat.RGBA32, false, false, texPtr); - _inputTex.filterMode = FilterMode.Bilinear; - _inputTex.hideFlags = HideFlags.HideAndDontSave; - _inputExternalTexPtr = texPtr; + tex.filterMode = FilterMode.Bilinear; + tex.hideFlags = HideFlags.HideAndDontSave; + _inputTextures[texPtr] = tex; } - else + _inputTex = tex; + } + + private void ClearRenderTextureCaches() + { + ClearTextureCache(_panelTextures); + ClearTextureCache(_inputTextures); + _tex = null; + _inputTex = null; + _panelTextureW = _panelTextureH = 0; + _inputTextureW = _inputTextureH = 0; + } + + private void ClearTextureCache(Dictionary cache) + { + foreach (var texture in cache.Values) { - _inputTex.UpdateExternalTexture(texPtr); + if (texture != null) DestroyImmediate(texture); } + cache.Clear(); } private void OnGUI() @@ -1698,7 +1728,8 @@ private void SwitchTo(string id, string title = null) var (pw, ph) = CurrentPanelSize(); var (iw, ih) = CurrentInputSize(); _viewId = (long)_native.AgentviewLoad(ProjectRoot, id, pw, ph, iw, ih, _effort, ClaudeCode.ClaudePath); - if (!string.IsNullOrEmpty(title)) titleContent = new GUIContent(title); + if (!string.IsNullOrEmpty(title)) + titleContent = UntermWindowTitle.Create(title, UntermWindowTitle.AgentIcon, titleContent); ApplyFonts(); ApplyAgentSettings(); RenderView(); Repaint(); diff --git a/Packages/dev.tnayuki.unterm/Editor/UntermCodeEditorWindow.cs b/Packages/dev.tnayuki.unterm/Editor/UntermCodeEditorWindow.cs index 694ee14..c1234d1 100644 --- a/Packages/dev.tnayuki.unterm/Editor/UntermCodeEditorWindow.cs +++ b/Packages/dev.tnayuki.unterm/Editor/UntermCodeEditorWindow.cs @@ -70,12 +70,17 @@ public sealed class UntermCodeEditorWindow : EditorWindow, IHasCustomMenu // clean, with no second copy of the buffer (just this u64). [SerializeField] private ulong _savedSerial; private double _lastExtCheck; // throttle for the on-disk change poll + private double _lastDiffPoll; + private const double ExternalCheckInterval = 1.0; + private const double DiffPollInterval = 0.05; // A background git-base fetch is in flight (kicked by SetPath/RefreshDiff); - // poll for its delivery only while set, instead of every tick forever. + // poll it at a bounded cadence while the editor is focused. // Editing surface texture (zero-copy wrap of the native MTLTexture). private Texture2D _tex; - private IntPtr _externalTexPtr; + private readonly Dictionary _surfaceTextures = + new Dictionary(); + private int _surfaceTextureW, _surfaceTextureH; // Last size/theme pushed to native, so RenderView (which runs per // keystroke/drag/scroll) skips those P/Invokes when nothing changed. @@ -180,7 +185,7 @@ private enum BarMode { None, Find, Replace, Goto } public static void OpenEmpty() { var w = CreateWindow(); - w.titleContent = new GUIContent("Code Editor"); + w.titleContent = UntermWindowTitle.Create("Code Editor", UntermWindowTitle.CodeEditorIcon, w.titleContent); w.minSize = new Vector2(320, 200); w.Show(); w.Focus(); @@ -529,7 +534,13 @@ private void OnFocus() #endif // Returning to the window: pick up edits made to the file externally, and // re-fetch the git base (a commit / branch switch may have happened away). - if (_native != null && _editorId != 0) { CheckExternalChange(); _native.EditorRefreshDiff(Eid); } + if (_native != null && _editorId != 0) + { + CheckExternalChange(); + if (!_preview) _native.EditorRefreshDiff(Eid); + _lastExtCheck = EditorApplication.timeSinceStartup; + _lastDiffPoll = 0d; + } } private void OnLostFocus() @@ -619,7 +630,7 @@ private void LoadNative() private void Teardown(bool keepView) { - if (_tex != null) { DestroyImmediate(_tex); _tex = null; } + ClearSurfaceTextureCache(); var native = _native; _native = null; @@ -686,18 +697,31 @@ private void UploadSurface(int iw, int ih) IntPtr texPtr = _native.EditorRawTexture(Eid); if (texPtr == IntPtr.Zero) return; - if (_tex == null || _tex.width != iw || _tex.height != ih || _externalTexPtr != texPtr) + if (_surfaceTextureW != iw || _surfaceTextureH != ih) { - if (_tex != null) DestroyImmediate(_tex); - _tex = Texture2D.CreateExternalTexture(iw, ih, TextureFormat.RGBA32, false, false, texPtr); - _tex.filterMode = FilterMode.Bilinear; - _tex.hideFlags = HideFlags.HideAndDontSave; - _externalTexPtr = texPtr; + ClearSurfaceTextureCache(); + _surfaceTextureW = iw; + _surfaceTextureH = ih; } - else + if (!_surfaceTextures.TryGetValue(texPtr, out var tex) || tex == null) { - _tex.UpdateExternalTexture(texPtr); + tex = Texture2D.CreateExternalTexture(iw, ih, TextureFormat.RGBA32, false, false, texPtr); + tex.filterMode = FilterMode.Bilinear; + tex.hideFlags = HideFlags.HideAndDontSave; + _surfaceTextures[texPtr] = tex; } + _tex = tex; + } + + private void ClearSurfaceTextureCache() + { + foreach (var texture in _surfaceTextures.Values) + { + if (texture != null) DestroyImmediate(texture); + } + _surfaceTextures.Clear(); + _tex = null; + _surfaceTextureW = _surfaceTextureH = 0; } // The internal method resolves once and the color it returns depends only @@ -1706,19 +1730,22 @@ private void OnEditorUpdate() // Pick up external changes (e.g. the Claude Code agent editing the file, // or `git add`/`reset`/commits run in a terminal changing the index) // even while this window stays focused — not just on OnFocus. Throttled. - if (EditorApplication.timeSinceStartup - _lastExtCheck > 1.0) + double now = EditorApplication.timeSinceStartup; + if (hasFocus && now - _lastExtCheck >= ExternalCheckInterval) { - _lastExtCheck = EditorApplication.timeSinceStartup; + _lastExtCheck = now; CheckExternalChange(); // still needed in preview (file may change on disk) // The diff gutter is an edit-mode surface: don't re-read git while // previewing (SetPreview refreshes it again on exit). if (!_preview) _native.EditorRefreshDiff(Eid); // background HEAD + index } - // A background git fetch may have finished: poll every tick (cheap bool); - // native applies it and returns true only when the git texts actually - // changed, so the steady-state 1s refresh doesn't cause re-renders. Skipped - // in preview (the gutter isn't shown, so there's nothing to apply). - if (!_preview && _native.EditorPollDiff(Eid)) { RenderView(); Repaint(); } + // Poll at a bounded cadence while focused. Losing focus pauses this work; + // OnFocus requests a fresh diff and resumes polling immediately. + if (hasFocus && !_preview && now - _lastDiffPoll >= DiffPollInterval) + { + _lastDiffPoll = now; + if (_native.EditorPollDiff(Eid)) { RenderView(); Repaint(); } + } PollSignatureTask(); // While a hint is shown, re-evaluate whenever the caret moved by ANY means // (arrows, click, edits) so it tracks the active parameter and closes once @@ -2272,8 +2299,9 @@ private void UpdateTitle() { // No manual dirty marker — Unity overlays the unsaved indicator from // hasUnsavedChanges. - string name = string.IsNullOrEmpty(_filePath) ? "Untitled" : Path.GetFileName(_filePath); - titleContent = new GUIContent(_preview ? name + " (Preview)" : name); + string title = string.IsNullOrEmpty(_filePath) ? "Untitled" : Path.GetFileName(_filePath); + if (_preview) title += " (Preview)"; + titleContent = UntermWindowTitle.Create(title, UntermWindowTitle.CodeEditorIcon, titleContent); } // Cmd/Ctrl+S while this window is focused saves the file (a window-context diff --git a/Packages/dev.tnayuki.unterm/Editor/UntermMcp.cs b/Packages/dev.tnayuki.unterm/Editor/UntermMcp.cs index a968c97..dca25b9 100644 --- a/Packages/dev.tnayuki.unterm/Editor/UntermMcp.cs +++ b/Packages/dev.tnayuki.unterm/Editor/UntermMcp.cs @@ -12,26 +12,25 @@ namespace Unterm.Editor /// /// There is no transport and no port: the agent (the native AgentView) is /// wired to this server in-process over the control protocol, so its tool - /// calls are dispatched straight into the queue. The bridge is brought up - /// eagerly at editor load (and re-adopted on every domain reload), so the - /// catalog is published before any agent session initializes — never a window - /// race where an agent connects to an empty tool list. - /// is idempotent, so the agent window calling it too is harmless. + /// calls are dispatched straight into the queue. In projects where MCP was + /// explicitly enabled, the bridge is brought up at editor load (and re-adopted + /// on every domain reload) so the catalog is published before an agent session + /// initializes. Disabled projects remain dormant. + /// is idempotent, so an agent window calling it too is harmless. /// [InitializeOnLoad] internal static class UntermMcp { + /// Native wrapper owned only while this project's MCP bridge is enabled. private static UntermNative _native; static UntermMcp() { #if UNITY_EDITOR_OSX || UNITY_EDITOR_WIN - // Bring the bridge up at editor load — eagerly, not lazily when the - // first agent window opens — so the tool catalog is always published - // up front. Deferred one tick so AssetDatabase (GUID -> plugin path) - // is ready; a domain reload re-runs this and re-adopts the native - // server, whose queued calls survive the reload. - EditorApplication.delayCall += EnsureStarted; + // Enabled projects publish before any agent session initializes. + // Disabled projects perform no native load or catalog discovery. + if (UntermMcpSecurity.Enabled) + EditorApplication.delayCall += EnsureStarted; #endif } @@ -42,7 +41,7 @@ static UntermMcp() public static void EnsureStarted() { #if UNITY_EDITOR_OSX || UNITY_EDITOR_WIN - if (_native != null) return; + if (!UntermMcpSecurity.Enabled || _native != null) return; try { _native = new UntermNative(); @@ -59,19 +58,32 @@ public static void EnsureStarted() #endif } - /// Republish the catalog after the user enables or disables MCP. + /// Start or stop the bridge after this project's MCP setting changes. public static void RefreshTools() { #if UNITY_EDITOR_OSX || UNITY_EDITOR_WIN - EnsureStarted(); - _native?.McpSetTools(UntermMcpServer.ToolsJson()); + if (UntermMcpSecurity.Enabled) + { + EnsureStarted(); + _native?.McpSetTools(UntermMcpServer.ToolsJson()); + return; + } + Stop(); #endif } // Run any queued tool calls on the main thread. private static void Poll() { - if (_native != null) UntermMcpServer.Poll(_native); + if (UntermMcpSecurity.Enabled && _native != null) UntermMcpServer.Poll(_native); + } + + /// Remove all active MCP work without loading native state for disabled projects. + private static void Stop() + { + EditorApplication.update -= Poll; + UntermMcpServer.Stop(_native); + _native = null; } } } diff --git a/Packages/dev.tnayuki.unterm/Editor/UntermMcpSecurity.cs b/Packages/dev.tnayuki.unterm/Editor/UntermMcpSecurity.cs index 99d61f3..bffb3ff 100644 --- a/Packages/dev.tnayuki.unterm/Editor/UntermMcpSecurity.cs +++ b/Packages/dev.tnayuki.unterm/Editor/UntermMcpSecurity.cs @@ -12,43 +12,120 @@ internal enum UntermToolRisk ReadOnly, Mutating, Dangerous, + Unclassified, + } + + /// Current-project trust granted to known MCP tool actions. + internal enum UntermMcpAccessPolicy + { + Prompt, + AllowMutating, + AllowDangerous, + } + + /// Pure authorization result before any Editor prompt is displayed. + internal enum UntermMcpAuthorization + { + Allow, + Prompt, + Deny, } /// - /// Owns the MCP trust boundary. The bridge is disabled by default, read-only calls - /// run without interruption once enabled, and every mutating or dangerous call - /// requires a fresh Editor approval. Approval is deliberately not remembered. + /// Owns the MCP trust boundary. Settings live in , + /// so trust is local to this Unity project and is not committed or shared. /// internal static class UntermMcpSecurity { internal const string EnabledPreferenceKey = "Unterm.Mcp.Enabled"; + internal const string AccessPolicyPreferenceKey = "Unterm.Mcp.AccessPolicy"; + internal const string ArbitraryCSharpPreferenceKey = "Unterm.Mcp.AllowArbitraryCSharp"; + + /// Whether the user explicitly enabled MCP for the current Unity project. + public static bool Enabled => ReadBool(EnabledPreferenceKey); - /// Whether the user explicitly enabled the in-editor MCP bridge. - public static bool Enabled => EditorPrefs.GetBool(EnabledPreferenceKey, false); + /// Unattended access granted for the current Unity project. + public static UntermMcpAccessPolicy AccessPolicy => ParseAccessPolicy(EditorUserSettings.GetConfigValue(AccessPolicyPreferenceKey)); - /// Ask for explicit enablement from Preferences; no unattended enable path exists. + /// Whether this project separately allows arbitrary C# without a one-shot prompt. + public static bool AllowArbitraryCSharp => ReadBool(ArbitraryCSharpPreferenceKey); + + /// Ask for explicit MCP enablement for this project. public static bool TryEnableWithConfirmation() { if (Enabled) return true; bool approved = EditorUtility.DisplayDialog( - "Enable Unterm MCP tools?", - "Claude Code will be able to request Unity Editor operations. Read-only requests run " + - "without another prompt. Every project mutation requires one-shot approval, and arbitrary " + - "C# execution is always treated as dangerous. Approval-required calls are denied in batch mode.", - "Enable MCP", + "Enable Unterm MCP tools for this project?", + "Claude Code will be able to request Unity Editor operations in this project. " + + "Read-only requests run without another prompt. Other requests follow the current-project " + + "access policy below; Prompt keeps one-shot approval and unattended prompt requests fail closed.", + "Enable for this project", "Cancel"); if (!approved) return false; - EditorPrefs.SetBool(EnabledPreferenceKey, true); + WriteBool(EnabledPreferenceKey, true); return true; } - /// Disable the bridge immediately and remove its published tool catalog. - public static void Disable() => EditorPrefs.SetBool(EnabledPreferenceKey, false); + /// Disable this project's bridge and revoke unattended trust. + public static void Disable() + { + WriteBool(EnabledPreferenceKey, false); + SetAccessPolicy(UntermMcpAccessPolicy.Prompt); + WriteBool(ArbitraryCSharpPreferenceKey, false); + } + + /// Apply a current-project policy change, confirming trust upgrades. + public static bool TrySetAccessPolicyWithConfirmation(UntermMcpAccessPolicy nextPolicy) + { + if (!IsValidAccessPolicy(nextPolicy)) return false; + UntermMcpAccessPolicy currentPolicy = AccessPolicy; + if (nextPolicy == currentPolicy) return true; + if (nextPolicy > currentPolicy) + { + string detail = nextPolicy == UntermMcpAccessPolicy.AllowDangerous + ? "Dangerous actions, including package changes and arbitrary menu execution, may run without a per-call prompt in this project. Arbitrary C# still requires its separate full-access opt-in." + : "Known project mutations may run without a per-call prompt in this project. Dangerous actions still use one-shot approval."; + bool approved = EditorUtility.DisplayDialog( + "Increase Unterm MCP access for this project?", + detail + " Batch requests are allowed only within the selected project policy.", + "Apply project policy", + "Cancel"); + if (!approved) return false; + } + + SetAccessPolicy(nextPolicy); + if (nextPolicy != UntermMcpAccessPolicy.AllowDangerous) + WriteBool(ArbitraryCSharpPreferenceKey, false); + return true; + } + + /// Apply the separate arbitrary-C# opt-in after a full-machine-access warning. + public static bool TrySetAllowArbitraryCSharpWithConfirmation(bool allow) + { + if (!allow) + { + WriteBool(ArbitraryCSharpPreferenceKey, false); + return true; + } + if (!Enabled || AccessPolicy != UntermMcpAccessPolicy.AllowDangerous) return false; + bool approved = EditorUtility.DisplayDialog( + "Allow arbitrary C# full-machine access?", + "unity_execute_code runs inside the Unity Editor process with your user account. It can read or " + + "write any accessible file, launch processes, use the network, inspect environment data, and change " + + "the project or Editor. Enabling this removes per-call prompts for this project only.", + "Allow full machine access", + "Cancel"); + if (!approved) return false; + WriteBool(ArbitraryCSharpPreferenceKey, true); + return true; + } /// Resolve a call's effective risk from its final tool name and action. - public static UntermToolRisk Classify(string toolName, JObject args) + public static UntermToolRisk Classify(string toolName, JObject args) => ClassifyAction(toolName, (string)args?["action"] ?? ""); + + /// Pure risk classification used by the runtime and EditMode policy tests. + internal static UntermToolRisk ClassifyAction(string toolName, string action) { - string action = (string)args?["action"] ?? ""; switch (toolName) { case "unity_editor": @@ -76,7 +153,6 @@ public static UntermToolRisk Classify(string toolName, JObject args) if (action == "get_info") return UntermToolRisk.ReadOnly; return string.IsNullOrEmpty(action) ? UntermToolRisk.Dangerous : UntermToolRisk.Mutating; case "unity_prefab": - // Both current defaults (instantiate/create) mutate project state. return UntermToolRisk.Mutating; case "unity_package": return action == "list" || action == "info" || string.IsNullOrEmpty(action) @@ -87,38 +163,58 @@ public static UntermToolRisk Classify(string toolName, JObject args) case "unity_execute_code": return UntermToolRisk.Dangerous; default: - // New tools fail closed until their risk is deliberately classified. - return UntermToolRisk.Dangerous; + return UntermToolRisk.Unclassified; } } - /// Authorize one call. Mutating approvals are never cached or allowlisted. - public static bool TryAuthorize(string toolName, JObject args, out string error) + /// Pure project-policy decision used by interactive and batch authorization paths. + internal static UntermMcpAuthorization ResolveAuthorization(bool enabled, UntermMcpAccessPolicy accessPolicy, UntermToolRisk risk, bool arbitraryCSharp, bool allowArbitraryCSharp, bool unattended) { - if (!Enabled) - { - error = "Unterm MCP tools are disabled. Enable them explicitly in Preferences > Unterm."; - return false; - } + if (!enabled) return UntermMcpAuthorization.Deny; + if (risk == UntermToolRisk.ReadOnly) return UntermMcpAuthorization.Allow; + if (!IsValidAccessPolicy(accessPolicy) || risk == UntermToolRisk.Unclassified) + return unattended ? UntermMcpAuthorization.Deny : UntermMcpAuthorization.Prompt; + + bool policyAllows = risk == UntermToolRisk.Mutating + ? accessPolicy >= UntermMcpAccessPolicy.AllowMutating + : accessPolicy >= UntermMcpAccessPolicy.AllowDangerous; + if (arbitraryCSharp) + policyAllows = accessPolicy == UntermMcpAccessPolicy.AllowDangerous && allowArbitraryCSharp; + if (policyAllows) return UntermMcpAuthorization.Allow; + return unattended ? UntermMcpAuthorization.Deny : UntermMcpAuthorization.Prompt; + } + /// Authorize one call using this project's policy and a one-shot prompt when required. + public static bool TryAuthorize(string toolName, JObject args, out string error) + { UntermToolRisk risk = Classify(toolName, args); - if (!RequiresOneShotApproval(risk)) + bool arbitraryCSharp = toolName == "unity_execute_code"; + UntermMcpAuthorization authorization = ResolveAuthorization( + Enabled, + AccessPolicy, + risk, + arbitraryCSharp, + AllowArbitraryCSharp, + Application.isBatchMode); + if (authorization == UntermMcpAuthorization.Allow) { error = null; return true; } - - if (Application.isBatchMode && !CanRunInBatchMode(risk)) + if (authorization == UntermMcpAuthorization.Deny) { - error = $"{toolName} was denied: {risk.ToString().ToLowerInvariant()} MCP calls require interactive one-shot approval."; + error = DeniedReason(toolName, risk, arbitraryCSharp); return false; } string arguments = args?.ToString(Formatting.Indented) ?? "{}"; if (arguments.Length > 4000) arguments = arguments.Substring(0, 4000) + "\n… (truncated)"; + string fullAccessWarning = arbitraryCSharp + ? "\n\nWarning: arbitrary C# has full access to the Editor process and everything your user account can access." + : ""; bool approved = EditorUtility.DisplayDialog( $"Approve {risk.ToString().ToLowerInvariant()} MCP action?", - $"Tool: {toolName}\nRisk: {risk}\n\nArguments:\n{arguments}\n\nThis approval applies to this call only.", + $"Tool: {toolName}\nRisk: {risk}\n\nArguments:\n{arguments}{fullAccessWarning}\n\nThis approval applies to this call only.", "Allow once", "Deny"); if (approved) @@ -131,12 +227,6 @@ public static bool TryAuthorize(string toolName, JObject args, out string error) return false; } - /// Whether a call needs a fresh user decision before execution. - internal static bool RequiresOneShotApproval(UntermToolRisk risk) => risk != UntermToolRisk.ReadOnly; - - /// Batch mode can run only calls that never require a prompt. - internal static bool CanRunInBatchMode(UntermToolRisk risk) => risk == UntermToolRisk.ReadOnly; - /// Fixed catalog annotation; runtime action classification remains authoritative. public static JObject CatalogAnnotations(string toolName) { @@ -154,10 +244,40 @@ public static JObject CatalogAnnotations(string toolName) public static string DescriptionSuffix(string toolName) { if (toolName == "unity_execute_code") - return " Security: dangerous; always requires one-shot Editor approval and is denied in batch mode."; + return " Security: full machine and Editor access; unattended execution requires this project's dangerous-action policy plus the separate arbitrary-C# opt-in. Otherwise it requires one-shot approval and is denied in batch mode."; if (toolName == "unity_capture" || toolName == "unity_find") - return " Security: read-only after MCP is explicitly enabled."; - return " Security: read actions run after MCP is enabled; mutation actions require one-shot Editor approval and are denied in batch mode."; + return " Security: read-only after MCP is explicitly enabled for this project."; + return " Security: governed by this project's Prompt, Allow Mutating, or Allow Dangerous policy; requests outside that policy require one-shot approval and are denied in batch mode."; + } + + /// Parse persisted project policy and fail closed on missing or invalid values. + internal static UntermMcpAccessPolicy ParseAccessPolicy(string value) + { + if (Enum.TryParse(value, out UntermMcpAccessPolicy parsed) && IsValidAccessPolicy(parsed)) return parsed; + return UntermMcpAccessPolicy.Prompt; + } + + /// Whether a numeric enum value is a supported access policy. + private static bool IsValidAccessPolicy(UntermMcpAccessPolicy policy) => policy >= UntermMcpAccessPolicy.Prompt && policy <= UntermMcpAccessPolicy.AllowDangerous; + + /// Persist a validated access policy in project-local Editor user settings. + private static void SetAccessPolicy(UntermMcpAccessPolicy policy) => EditorUserSettings.SetConfigValue(AccessPolicyPreferenceKey, policy.ToString()); + + /// Read a project-local boolean with a fail-closed false default. + private static bool ReadBool(string key) => string.Equals(EditorUserSettings.GetConfigValue(key), bool.TrueString, StringComparison.OrdinalIgnoreCase); + + /// Persist a project-local boolean without writing project assets. + private static void WriteBool(string key, bool value) => EditorUserSettings.SetConfigValue(key, value ? bool.TrueString : bool.FalseString); + + /// Explain why an unattended or disabled call was denied. + private static string DeniedReason(string toolName, UntermToolRisk risk, bool arbitraryCSharp) + { + if (!Enabled) return "Unterm MCP tools are disabled for this project. Enable them explicitly in Preferences > Unterm."; + if (risk == UntermToolRisk.Unclassified) + return $"{toolName} was denied: unclassified MCP tools cannot run unattended."; + if (arbitraryCSharp && !AllowArbitraryCSharp) + return $"{toolName} was denied: batch execution requires Allow Dangerous plus the separate arbitrary-C# full-machine-access opt-in for this project."; + return $"{toolName} was denied: {risk.ToString().ToLowerInvariant()} calls are not allowed unattended by this project's {AccessPolicy} policy."; } } } diff --git a/Packages/dev.tnayuki.unterm/Editor/UntermMcpServer.cs b/Packages/dev.tnayuki.unterm/Editor/UntermMcpServer.cs index 0f16484..6864907 100644 --- a/Packages/dev.tnayuki.unterm/Editor/UntermMcpServer.cs +++ b/Packages/dev.tnayuki.unterm/Editor/UntermMcpServer.cs @@ -35,6 +35,7 @@ private sealed class Tool /// Register default tools once and subscribe to console logs. public static void StartLogCapture() { + if (!UntermMcpSecurity.Enabled) return; EnsureTools(); if (!_logHooked) { @@ -53,10 +54,13 @@ public static void StopLogCapture() } /// The tool catalog as a JSON array for the native MCP server (set_tools). - public static string ToolsJson() + public static string ToolsJson() => ToolsJson(UntermMcpSecurity.Enabled); + + /// Build the catalog only when the caller's project-scoped setting is enabled. + internal static string ToolsJson(bool enabled) { + if (!enabled) return "[]"; EnsureTools(); - if (!UntermMcpSecurity.Enabled) return "[]"; var arr = new JArray(); foreach (var t in _tools.Values) arr.Add(new JObject @@ -69,6 +73,37 @@ public static string ToolsJson() return arr.ToString(Formatting.None); } + /// Reject active work, unpublish tools, and release all managed MCP state. + public static void Stop(UntermNative native) + { + string disabledResult = ToolResult("error: Unterm MCP tools were disabled for this project.", true); + try + { + if (native != null) + { + native.McpSetTools("[]"); + foreach ((ulong id, UntermDeferredResult _) in _pending) + native.McpRespond(id, disabledResult); + string callJson; + while (!string.IsNullOrEmpty(callJson = native.McpNextCall())) + { + JObject call = JObject.Parse(callJson); + native.McpRespond((ulong)call["id"], disabledResult); + } + } + } + finally + { + _pending.Clear(); + StopLogCapture(); + _tools.Clear(); + lock (_logs) _logs.Clear(); + } + } + + /// Test seam proving disabled catalog requests do not initialize tools. + internal static bool ToolsInitialized => _tools.Count > 0; + // Calls whose handler returned an UntermDeferredResult: polled each tick // until the result is ready. Lost on a domain reload — the native side // then times the call out (~30s), which is the intended backstop. @@ -78,7 +113,7 @@ public static string ToolsJson() /// (current) main thread, and post results back. Call from EditorApplication.update. public static void Poll(UntermNative native) { - if (native == null) return; + if (!UntermMcpSecurity.Enabled || native == null) return; // Finish deferred calls whose result is now available. for (int i = _pending.Count - 1; i >= 0; i--) diff --git a/Packages/dev.tnayuki.unterm/Editor/UntermSettingsProvider.cs b/Packages/dev.tnayuki.unterm/Editor/UntermSettingsProvider.cs index 955750e..3d8302c 100644 --- a/Packages/dev.tnayuki.unterm/Editor/UntermSettingsProvider.cs +++ b/Packages/dev.tnayuki.unterm/Editor/UntermSettingsProvider.cs @@ -131,11 +131,14 @@ private static void OnGui() EditorGUILayout.Space(); EditorGUILayout.LabelField("Unity MCP", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + "These settings apply only to the current Unity project and are stored in local, " + + "uncommitted Editor user settings.", + MessageType.Info); bool mcpEnabled = UntermMcpSecurity.Enabled; bool nextMcpEnabled = EditorGUILayout.Toggle( - new GUIContent("Enable MCP tools", - "Disabled by default. Read-only calls run after enablement; every mutation " + - "requires one-shot Editor approval."), + new GUIContent("Enable MCP tools for this project", + "Disabled by default. Requests follow the current-project access policy."), mcpEnabled); if (nextMcpEnabled != mcpEnabled) { @@ -145,11 +148,46 @@ private static void OnGui() UntermMcpSecurity.Disable(); UntermMcp.RefreshTools(); } + if (!UntermMcpSecurity.Enabled) + { + EditorGUILayout.HelpBox( + "MCP is disabled. Unterm does not load MCP native state, build the Unity tool catalog, capture logs, or poll requests.", + MessageType.Info); + return; + } + + UntermMcpAccessPolicy accessPolicy = UntermMcpSecurity.AccessPolicy; + UntermMcpAccessPolicy nextAccessPolicy = (UntermMcpAccessPolicy)EditorGUILayout.EnumPopup( + new GUIContent("Current-project access", + "Prompt keeps one-shot approval. Allow Mutating permits known mutations without prompts. " + + "Allow Dangerous also permits known dangerous actions without prompts."), + accessPolicy); + if (nextAccessPolicy != accessPolicy) + UntermMcpSecurity.TrySetAccessPolicyWithConfirmation(nextAccessPolicy); + + string policyMessage = UntermMcpSecurity.AccessPolicy switch + { + UntermMcpAccessPolicy.AllowMutating => "Known mutations may run unattended in this project. Dangerous actions still require one-shot approval; batch dangerous requests fail closed.", + UntermMcpAccessPolicy.AllowDangerous => "Known mutating and dangerous actions may run unattended in this project. Unclassified tools are never auto-allowed.", + _ => "Mutating and dangerous actions require fresh one-shot approval. Requests that need a prompt fail closed in batch mode.", + }; + EditorGUILayout.HelpBox(policyMessage, MessageType.Warning); + + using (new EditorGUI.DisabledScope(UntermMcpSecurity.AccessPolicy != UntermMcpAccessPolicy.AllowDangerous)) + { + bool allowArbitraryCSharp = UntermMcpSecurity.AllowArbitraryCSharp; + bool nextAllowArbitraryCSharp = EditorGUILayout.Toggle( + new GUIContent("Allow Arbitrary C#", + "Separate opt-in that lets unity_execute_code run without a per-call prompt in this project."), + allowArbitraryCSharp); + if (nextAllowArbitraryCSharp != allowArbitraryCSharp) + UntermMcpSecurity.TrySetAllowArbitraryCSharpWithConfirmation(nextAllowArbitraryCSharp); + } EditorGUILayout.HelpBox( - UntermMcpSecurity.Enabled - ? "MCP is enabled. Mutating and dangerous calls still require one-shot approval." - : "MCP is disabled. Claude Code receives no Unity MCP tool catalog.", - UntermMcpSecurity.Enabled ? MessageType.Warning : MessageType.Info); + "Arbitrary C# has full machine and Editor access under your user account: it can read or write " + + "files, launch processes, use the network, inspect environment data, and modify this project. " + + "It runs unattended only when Allow Dangerous and the separate opt-in above are both enabled.", + MessageType.Error); } private static void DrawAction() diff --git a/Packages/dev.tnayuki.unterm/Editor/UntermWindow.cs b/Packages/dev.tnayuki.unterm/Editor/UntermWindow.cs index 348ad48..78de21c 100644 --- a/Packages/dev.tnayuki.unterm/Editor/UntermWindow.cs +++ b/Packages/dev.tnayuki.unterm/Editor/UntermWindow.cs @@ -80,6 +80,17 @@ private static void PruneOrphanRestoreFiles() private readonly System.Collections.Generic.Dictionary _extTex = new System.Collections.Generic.Dictionary(); private int _extW, _extH; + // Size sent to native. Output can dirty the terminal many times without a + // resize, so avoid repeating the resize P/Invoke on every rendered frame. + private uint _sentW, _sentH; + private float _sentPpp; + // A restored window can exist before Unity's graphics device is available. + // Retry zero-copy setup with a short bounded backoff instead of rendering on + // every editor tick indefinitely while the native texture is unavailable. + private const double GpuRetryMinSeconds = 0.05; + private const double GpuRetryMaxSeconds = 0.5; + private double _nextGpuRetryAt; + private double _gpuRetryDelay = GpuRetryMinSeconds; private string _status = ""; private bool _alive = true; @@ -134,7 +145,7 @@ public static void OpenNew() // focus) so the new window cascades off it instead of stacking on top. var from = focusedWindow as UntermWindow; var w = CreateWindow(); - w.titleContent = new GUIContent("Terminal"); + w.titleContent = UntermWindowTitle.Create("Terminal", UntermWindowTitle.TerminalIcon, w.titleContent); w.minSize = new Vector2(240, 120); PlaceCascaded(w, from); w.Show(); @@ -165,7 +176,7 @@ internal static UntermWindow CreateRunning(string title, string command) { var from = focusedWindow as UntermWindow; var w = CreateWindow(); - w.titleContent = new GUIContent(title); + w.titleContent = UntermWindowTitle.Create(title, UntermWindowTitle.TerminalIcon, w.titleContent); w.minSize = new Vector2(240, 120); PlaceCascaded(w, from); w.Show(); @@ -258,6 +269,9 @@ private void OnEnable() { s_reloading = false; wantsMouseMove = false; + _sentW = _sentH = 0; + _sentPpp = 0f; + ResetGpuRetry(); AssemblyReloadEvents.beforeAssemblyReload += OnBeforeReload; EditorApplication.update += OnEditorUpdate; LoadNative(); @@ -462,6 +476,9 @@ private void Teardown(bool keepTerminal) ClearExtCache(); _extW = 0; _extH = 0; + _sentW = _sentH = 0; + _sentPpp = 0f; + ResetGpuRetry(); if (_tex != null) { if (_externalTexPtr == IntPtr.Zero) DestroyImmediate(_tex); @@ -516,8 +533,10 @@ private void OnEditorUpdate() if (_native == null || Tid == 0) return; bool repaint = false; + bool rendered = false; + bool dirty = _native.Dirty(Tid); - if (_native.Dirty(Tid) || _tex == null) + if (dirty) { // Title / liveness changes always arrive with the dirty flag (their // native events set it), so they're synced here rather than @@ -527,17 +546,20 @@ private void OnEditorUpdate() string want = string.IsNullOrEmpty(title) ? "Terminal" : title; if (!_alive) want += " (exited)"; if (titleContent.text != want) - titleContent = new GUIContent(want); + titleContent = UntermWindowTitle.Create(want, UntermWindowTitle.TerminalIcon, titleContent); + } - // _tex == null means the shared texture isn't ready yet (Windows: - // Unity's D3D device wasn't captured when this terminal was built, - // e.g. a window restored at editor startup). Keep re-rendering so - // the surface self-heals once the device appears. Cheap; stops as - // soon as _tex is set. Also covers an exited terminal's first frame. + // A live surface renders dirty output immediately. Without a surface, + // coalesce dirty output until the bounded GPU retry is due; the native + // dirty state is reflected by that next render. + if ((dirty && _tex != null) || (_tex == null && GpuRetryDue())) + { RenderNow(); repaint = true; + rendered = true; } - else if (_native.Present(Tid)) + + if (!rendered && _native.Present(Tid)) { // No new output, but a finished frame was just promoted to the // front (double-buffered zero-copy); point _tex at it and repaint. @@ -552,11 +574,33 @@ private void RenderNow() { if (_native == null || Tid == 0) return; var (w, h) = CurrentPixelSize(); - _native.Resize(Tid, w, h, EditorGUIUtility.pixelsPerPoint); + float ppp = EditorGUIUtility.pixelsPerPoint; + if (w != _sentW || h != _sentH || ppp != _sentPpp) + { + _sentW = w; + _sentH = h; + _sentPpp = ppp; + _native.Resize(Tid, w, h, ppp); + } _native.Render(Tid); UploadZeroCopy((int)w, (int)h); } + private bool GpuRetryDue() => + EditorApplication.timeSinceStartup >= _nextGpuRetryAt; + + private void ResetGpuRetry() + { + _nextGpuRetryAt = 0d; + _gpuRetryDelay = GpuRetryMinSeconds; + } + + private void ScheduleGpuRetry() + { + _nextGpuRetryAt = EditorApplication.timeSinceStartup + _gpuRetryDelay; + _gpuRetryDelay = Math.Min(GpuRetryMaxSeconds, _gpuRetryDelay * 2d); + } + private void ApplyTheme() { Color32 bg, fg, cursor; @@ -590,8 +634,10 @@ private void UploadZeroCopy(int iw, int ih) { _tex = null; _status = "GPU not ready"; + ScheduleGpuRetry(); return; } + ResetGpuRetry(); // On resize the native textures are recreated, so drop stale wrappers. if (_extW != iw || _extH != ih) { @@ -635,6 +681,12 @@ private void OnFocus() Input.imeCompositionMode = IMECompositionMode.On; #endif if (_native != null && Tid != 0) _native.SetFocus(Tid, true); + if (_native != null && Tid != 0 && _tex == null) + { + ResetGpuRetry(); + RenderNow(); + Repaint(); + } } private void OnLostFocus() @@ -665,8 +717,14 @@ private void OnGUI() // Re-render when the draw area no longer matches the texture (resize). var (cw, ch) = CurrentPixelSize(); - if (_native != null && Tid != 0 && - (_tex == null || _tex.width != (int)cw || _tex.height != (int)ch)) + float ppp = EditorGUIUtility.pixelsPerPoint; + bool sizeChanged = cw != _sentW || ch != _sentH || ppp != _sentPpp; + if (_native != null && Tid != 0 && sizeChanged) + { + ResetGpuRetry(); + RenderNow(); + } + else if (_native != null && Tid != 0 && _tex == null && GpuRetryDue()) { RenderNow(); } diff --git a/Packages/dev.tnayuki.unterm/Editor/UntermWindowTitle.cs b/Packages/dev.tnayuki.unterm/Editor/UntermWindowTitle.cs new file mode 100644 index 0000000..1d495a0 --- /dev/null +++ b/Packages/dev.tnayuki.unterm/Editor/UntermWindowTitle.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; + +namespace Unterm.Editor +{ + /// Creates EditorWindow titles without dropping their tab icons on live title changes. + internal static class UntermWindowTitle + { + internal const string TerminalIcon = "UnityEditor.ConsoleWindow"; + internal const string AgentIcon = "UnityEditor.ConsoleWindow"; + internal const string CodeEditorIcon = "cs Script Icon"; + + private static readonly Dictionary Icons = new(); + + internal static GUIContent Create(string text, string iconName, GUIContent current = null) + { + Texture icon = current?.image; + if (icon == null && !string.IsNullOrEmpty(iconName)) + { + if (!Icons.TryGetValue(iconName, out icon) || icon == null) + { + icon = EditorGUIUtility.IconContent(iconName)?.image; + if (icon != null) Icons[iconName] = icon; + } + } + + return new GUIContent(text ?? string.Empty, icon, current?.tooltip ?? string.Empty); + } + } +} diff --git a/Packages/dev.tnayuki.unterm/Editor/UntermWindowTitle.cs.meta b/Packages/dev.tnayuki.unterm/Editor/UntermWindowTitle.cs.meta new file mode 100644 index 0000000..d4aa1b2 --- /dev/null +++ b/Packages/dev.tnayuki.unterm/Editor/UntermWindowTitle.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e2cf12d2a1ce4aa9b77e5b58175bcfa1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/dev.tnayuki.unterm/Tests/Editor/McpSecurityTests.cs b/Packages/dev.tnayuki.unterm/Tests/Editor/McpSecurityTests.cs index 7c2f717..730ed25 100644 --- a/Packages/dev.tnayuki.unterm/Tests/Editor/McpSecurityTests.cs +++ b/Packages/dev.tnayuki.unterm/Tests/Editor/McpSecurityTests.cs @@ -1,55 +1,114 @@ -using Newtonsoft.Json.Linq; using NUnit.Framework; using Unterm.Editor; namespace Unterm.Editor.Tests { - /// Policy tests for the final MCP tool name and action boundary. + /// Policy tests for the final MCP tool name, action, and project trust boundary. public class McpSecurityTests { - [TestCase("unity_editor", "state", UntermToolRisk.ReadOnly)] - [TestCase("unity_editor", "play", UntermToolRisk.Mutating)] - [TestCase("unity_scene", "hierarchy", UntermToolRisk.ReadOnly)] - [TestCase("unity_scene", "save", UntermToolRisk.Mutating)] - [TestCase("unity_menu", "search", UntermToolRisk.ReadOnly)] - [TestCase("unity_menu", "execute", UntermToolRisk.Dangerous)] - [TestCase("unity_package", "list", UntermToolRisk.ReadOnly)] - [TestCase("unity_package", "add", UntermToolRisk.Dangerous)] - [TestCase("unity_prefab", "instantiate", UntermToolRisk.Mutating)] - public void Classify_UsesFinalAction(string tool, string action, UntermToolRisk expected) + [TestCase("unity_editor", "state", (int)UntermToolRisk.ReadOnly)] + [TestCase("unity_editor", "play", (int)UntermToolRisk.Mutating)] + [TestCase("unity_scene", "hierarchy", (int)UntermToolRisk.ReadOnly)] + [TestCase("unity_scene", "save", (int)UntermToolRisk.Mutating)] + [TestCase("unity_menu", "search", (int)UntermToolRisk.ReadOnly)] + [TestCase("unity_menu", "execute", (int)UntermToolRisk.Dangerous)] + [TestCase("unity_package", "list", (int)UntermToolRisk.ReadOnly)] + [TestCase("unity_package", "add", (int)UntermToolRisk.Dangerous)] + [TestCase("unity_prefab", "instantiate", (int)UntermToolRisk.Mutating)] + [TestCase("unity_execute_code", "", (int)UntermToolRisk.Dangerous)] + public void Classify_UsesFinalAction(string tool, string action, int expected) { - Assert.AreEqual(expected, UntermMcpSecurity.Classify(tool, new JObject { ["action"] = action })); + Assert.AreEqual((UntermToolRisk)expected, UntermMcpSecurity.ClassifyAction(tool, action)); + } + + [TestCase(false, (int)UntermMcpAccessPolicy.AllowDangerous, (int)UntermToolRisk.ReadOnly, false, false, true, (int)UntermMcpAuthorization.Deny)] + [TestCase(true, (int)UntermMcpAccessPolicy.Prompt, (int)UntermToolRisk.ReadOnly, false, false, true, (int)UntermMcpAuthorization.Allow)] + [TestCase(true, (int)UntermMcpAccessPolicy.Prompt, (int)UntermToolRisk.Mutating, false, false, false, (int)UntermMcpAuthorization.Prompt)] + [TestCase(true, (int)UntermMcpAccessPolicy.Prompt, (int)UntermToolRisk.Mutating, false, false, true, (int)UntermMcpAuthorization.Deny)] + [TestCase(true, (int)UntermMcpAccessPolicy.AllowMutating, (int)UntermToolRisk.Mutating, false, false, true, (int)UntermMcpAuthorization.Allow)] + [TestCase(true, (int)UntermMcpAccessPolicy.AllowMutating, (int)UntermToolRisk.Dangerous, false, false, false, (int)UntermMcpAuthorization.Prompt)] + [TestCase(true, (int)UntermMcpAccessPolicy.AllowMutating, (int)UntermToolRisk.Dangerous, false, false, true, (int)UntermMcpAuthorization.Deny)] + [TestCase(true, (int)UntermMcpAccessPolicy.AllowDangerous, (int)UntermToolRisk.Dangerous, false, false, true, (int)UntermMcpAuthorization.Allow)] + public void ResolveAuthorization_AppliesProjectPolicy(bool enabled, int accessPolicyValue, int riskValue, bool arbitraryCSharp, bool allowArbitraryCSharp, bool unattended, int expectedValue) + { + UntermMcpAccessPolicy accessPolicy = (UntermMcpAccessPolicy)accessPolicyValue; + UntermToolRisk risk = (UntermToolRisk)riskValue; + UntermMcpAuthorization actual = UntermMcpSecurity.ResolveAuthorization(enabled, accessPolicy, risk, arbitraryCSharp, allowArbitraryCSharp, unattended); + Assert.AreEqual((UntermMcpAuthorization)expectedValue, actual); + } + + [TestCase((int)UntermMcpAccessPolicy.Prompt, false, false, (int)UntermMcpAuthorization.Prompt)] + [TestCase((int)UntermMcpAccessPolicy.Prompt, true, true, (int)UntermMcpAuthorization.Deny)] + [TestCase((int)UntermMcpAccessPolicy.AllowMutating, true, true, (int)UntermMcpAuthorization.Deny)] + [TestCase((int)UntermMcpAccessPolicy.AllowDangerous, false, false, (int)UntermMcpAuthorization.Prompt)] + [TestCase((int)UntermMcpAccessPolicy.AllowDangerous, false, true, (int)UntermMcpAuthorization.Deny)] + [TestCase((int)UntermMcpAccessPolicy.AllowDangerous, true, true, (int)UntermMcpAuthorization.Allow)] + public void ExecuteCode_RequiresDangerousPolicyAndSeparateOptInForUnattendedAccess(int accessPolicyValue, bool allowArbitraryCSharp, bool unattended, int expectedValue) + { + UntermMcpAccessPolicy accessPolicy = (UntermMcpAccessPolicy)accessPolicyValue; + UntermMcpAuthorization actual = UntermMcpSecurity.ResolveAuthorization(true, accessPolicy, UntermToolRisk.Dangerous, true, allowArbitraryCSharp, unattended); + Assert.AreEqual((UntermMcpAuthorization)expectedValue, actual); } [Test] - public void ExecuteCode_IsAlwaysDangerousAndInteractive() + public void UnknownTools_NeverBecomeAutoAllowed() { - var risk = UntermMcpSecurity.Classify("unity_execute_code", new JObject()); - Assert.AreEqual(UntermToolRisk.Dangerous, risk); - Assert.IsTrue(UntermMcpSecurity.RequiresOneShotApproval(risk)); - Assert.IsFalse(UntermMcpSecurity.CanRunInBatchMode(risk)); + UntermToolRisk risk = UntermMcpSecurity.ClassifyAction("unity_future_tool", ""); + Assert.AreEqual(UntermToolRisk.Unclassified, risk); + Assert.AreEqual( + UntermMcpAuthorization.Prompt, + UntermMcpSecurity.ResolveAuthorization(true, UntermMcpAccessPolicy.AllowDangerous, risk, false, true, false)); + Assert.AreEqual( + UntermMcpAuthorization.Deny, + UntermMcpSecurity.ResolveAuthorization(true, UntermMcpAccessPolicy.AllowDangerous, risk, false, true, true)); } [Test] - public void UnknownTools_FailClosed() + public void InvalidOrMissingPersistedPolicy_FailsClosedToPrompt() { - Assert.AreEqual(UntermToolRisk.Dangerous, - UntermMcpSecurity.Classify("unity_future_tool", new JObject())); + Assert.AreEqual(UntermMcpAccessPolicy.Prompt, UntermMcpSecurity.ParseAccessPolicy(null)); + Assert.AreEqual(UntermMcpAccessPolicy.Prompt, UntermMcpSecurity.ParseAccessPolicy("AllowEverything")); + Assert.AreEqual(UntermMcpAccessPolicy.AllowMutating, UntermMcpSecurity.ParseAccessPolicy("AllowMutating")); + Assert.AreEqual( + UntermMcpAuthorization.Deny, + UntermMcpSecurity.ResolveAuthorization(true, (UntermMcpAccessPolicy)999, UntermToolRisk.Dangerous, false, false, true)); + Assert.AreEqual( + UntermMcpAuthorization.Prompt, + UntermMcpSecurity.ResolveAuthorization(true, (UntermMcpAccessPolicy)999, UntermToolRisk.Dangerous, false, false, false)); + } + + [Test] + public void DisabledCatalogRequest_DoesNotInitializeTools() + { + bool initializedBefore = UntermMcpServer.ToolsInitialized; + Assert.AreEqual("[]", UntermMcpServer.ToolsJson(false)); + Assert.AreEqual(initializedBefore, UntermMcpServer.ToolsInitialized); + } + + [Test] + public void StopWithoutNative_ClearsManagedCatalog() + { + UntermMcpServer.ToolsJson(true); + Assert.IsTrue(UntermMcpServer.ToolsInitialized); + + UntermMcpServer.Stop(null); + + Assert.IsFalse(UntermMcpServer.ToolsInitialized); } [Test] public void MissingActions_MatchReadDefaultsAndFailClosedForExecutionDefaults() { Assert.AreEqual(UntermToolRisk.ReadOnly, - UntermMcpSecurity.Classify("unity_asset", new JObject()), "asset defaults to find"); + UntermMcpSecurity.ClassifyAction("unity_asset", ""), "asset defaults to find"); Assert.AreEqual(UntermToolRisk.ReadOnly, - UntermMcpSecurity.Classify("unity_script", new JObject()), "script defaults to read"); + UntermMcpSecurity.ClassifyAction("unity_script", ""), "script defaults to read"); Assert.AreEqual(UntermToolRisk.Dangerous, - UntermMcpSecurity.Classify("unity_menu", new JObject()), "menu defaults to execute"); + UntermMcpSecurity.ClassifyAction("unity_menu", ""), "menu defaults to execute"); Assert.AreEqual(UntermToolRisk.Dangerous, - UntermMcpSecurity.Classify("unity_material", new JObject()), "material has no read-only default"); + UntermMcpSecurity.ClassifyAction("unity_material", ""), "material has no read-only default"); Assert.AreEqual(UntermToolRisk.Mutating, - UntermMcpSecurity.Classify("unity_prefab", new JObject()), "prefab defaults to instantiate"); + UntermMcpSecurity.ClassifyAction("unity_prefab", ""), "prefab defaults to instantiate"); } } } From 79af3cb1b8c2c70555e53088638a1414256ef72c Mon Sep 17 00:00:00 2001 From: Miguel Lopez Date: Mon, 13 Jul 2026 19:07:15 -0400 Subject: [PATCH 2/7] Document configurable MCP access policy --- .../Editor/UntermExecuteCodeTools.cs | 5 +++-- Packages/dev.tnayuki.unterm/README.md | 14 +++++++++----- README.md | 12 ++++++++---- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/Packages/dev.tnayuki.unterm/Editor/UntermExecuteCodeTools.cs b/Packages/dev.tnayuki.unterm/Editor/UntermExecuteCodeTools.cs index 93e8901..1e03066 100644 --- a/Packages/dev.tnayuki.unterm/Editor/UntermExecuteCodeTools.cs +++ b/Packages/dev.tnayuki.unterm/Editor/UntermExecuteCodeTools.cs @@ -20,8 +20,9 @@ namespace Unterm.Editor /// reload, no temp files, and a compile error is isolated to this call. /// /// WARNING: runs arbitrary code in the editor. The central MCP policy classifies - /// this tool as dangerous, requires one-shot Editor approval for every call, and - /// denies it in batch mode. + /// this tool as dangerous. It requires one-shot Editor approval unless this + /// project has both Allow Dangerous and the separate full-machine-access opt-in; + /// unattended requests that do not satisfy both gates are denied. /// internal sealed class UntermExecuteCodeTools : IUntermToolGroup { diff --git a/Packages/dev.tnayuki.unterm/README.md b/Packages/dev.tnayuki.unterm/README.md index 7e6ea17..faecf9c 100644 --- a/Packages/dev.tnayuki.unterm/README.md +++ b/Packages/dev.tnayuki.unterm/README.md @@ -53,11 +53,15 @@ transcript all open there. ## Security boundary Unity MCP tools are disabled by default and require explicit confirmation in -**Preferences ▸ Unterm**. Once enabled, read-only requests can run unattended; -every mutating or dangerous call requires one-shot Editor approval. Such calls -are denied in batch mode, unknown tools fail closed, and arbitrary C# execution -is always dangerous. Claude permission-bypass modes are rejected, and the -managed Claude process receives an explicit environment allowlist. +**Preferences ▸ Unterm**. Trust is stored only in this project's uncommitted +Editor user settings. The default Prompt policy allows reads and requires +one-shot approval for mutations and dangerous actions; confirmed Allow Mutating +and Allow Dangerous policies can permit known actions unattended. Requests +outside the selected policy are denied in batch mode, and unclassified tools +never auto-run. Arbitrary C# remains dangerous and runs unattended only with +both Allow Dangerous and its separate full-machine-access confirmation. Claude +permission-bypass modes are rejected, and the managed Claude process receives +an explicit environment allowlist. ## Platform diff --git a/README.md b/README.md index 386f430..06e5e9a 100644 --- a/README.md +++ b/README.md @@ -63,10 +63,14 @@ transcript all open there. ## Security boundary Unity MCP tools are disabled by default. Enabling them requires confirmation in -**Preferences ▸ Unterm**. Read-only calls can then run unattended, while every -mutating or dangerous call requires a fresh Editor approval. Approval-required -calls are denied in batch mode, unknown tools fail closed, and arbitrary C# -execution is always dangerous. Claude Code permission-bypass modes are rejected. +**Preferences ▸ Unterm** and stores trust only in this project's uncommitted +Editor user settings. The default Prompt policy allows reads and requires fresh +approval for mutations and dangerous actions; confirmed Allow Mutating and Allow +Dangerous policies can permit known actions unattended. Requests outside the +selected policy are denied in batch mode, and unclassified tools never auto-run. +Arbitrary C# remains dangerous and runs unattended only with both Allow Dangerous +and its separate full-machine-access confirmation. Claude Code permission-bypass +modes are rejected. The managed Claude child process receives an explicit environment allowlist, so host credentials and unrelated secrets are not inherited by default. From 925f89febeb9ccefc160488958ecaf8599f3d909 Mon Sep 17 00:00:00 2001 From: Miguel Lopez Date: Mon, 13 Jul 2026 19:12:06 -0400 Subject: [PATCH 3/7] Expose debugger in toolkit menu --- Packages/dev.tnayuki.unterm/Editor/ToolkitMenuItems.cs | 9 ++++++++- .../dev.tnayuki.unterm/Editor/UntermDebuggerLauncher.cs | 4 ++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Packages/dev.tnayuki.unterm/Editor/ToolkitMenuItems.cs b/Packages/dev.tnayuki.unterm/Editor/ToolkitMenuItems.cs index aafe8e4..39ccae3 100644 --- a/Packages/dev.tnayuki.unterm/Editor/ToolkitMenuItems.cs +++ b/Packages/dev.tnayuki.unterm/Editor/ToolkitMenuItems.cs @@ -6,6 +6,7 @@ namespace Unterm.Editor internal static class ToolkitMenuItems { private const string ClaudeMenuPath = "Tools/Unity Cursor Toolkit/Unterm/Claude Code"; + private const string DebuggerMenuPath = "Tools/Unity Cursor Toolkit/Unterm/Debugger (Standalone Process)"; [MenuItem("Tools/Unity Cursor Toolkit/Unterm/New Terminal", priority = 300)] private static void OpenTerminal() => UntermWindow.OpenNew(); @@ -19,7 +20,13 @@ internal static class ToolkitMenuItems [MenuItem("Tools/Unity Cursor Toolkit/Unterm/Code Editor", priority = 302)] private static void OpenCodeEditor() => UntermCodeEditorWindow.OpenEmpty(); - [MenuItem("Tools/Unity Cursor Toolkit/Unterm/Settings", priority = 303)] + [MenuItem(DebuggerMenuPath, priority = 303)] + private static void OpenDebugger() => UntermDebuggerLauncher.Open(); + + [MenuItem(DebuggerMenuPath, validate = true)] + private static bool OpenDebuggerValidate() => UntermDebuggerLauncher.OpenValidate(); + + [MenuItem("Tools/Unity Cursor Toolkit/Unterm/Settings", priority = 304)] private static void OpenSettings() => SettingsService.OpenUserPreferences("Preferences/Unterm"); } } diff --git a/Packages/dev.tnayuki.unterm/Editor/UntermDebuggerLauncher.cs b/Packages/dev.tnayuki.unterm/Editor/UntermDebuggerLauncher.cs index f39c98b..b47eb3c 100644 --- a/Packages/dev.tnayuki.unterm/Editor/UntermDebuggerLauncher.cs +++ b/Packages/dev.tnayuki.unterm/Editor/UntermDebuggerLauncher.cs @@ -76,10 +76,10 @@ static UntermDebuggerLauncher() /// arms the current breakpoints and waits for Play — you can also use Pause). /// Greyed out until debugging is enabled in Preferences > Unterm. [MenuItem("Window/Unterm/Debugger (Standalone Process) %#d")] - private static void Open() => EnsureRunning(); + internal static void Open() => EnsureRunning(); [MenuItem("Window/Unterm/Debugger (Standalone Process) %#d", validate = true)] - private static bool OpenValidate() => UntermDebuggerPrefs.Enabled; + internal static bool OpenValidate() => UntermDebuggerPrefs.Enabled; private static void OnPlayModeChanged(PlayModeStateChange state) { From f3f0adb3ee09e99947b830a2ce387b736d824da2 Mon Sep 17 00:00:00 2001 From: Miguel Lopez Date: Mon, 13 Jul 2026 19:31:13 -0400 Subject: [PATCH 4/7] Refresh source provenance after upstream sync --- .github/workflows/split-upm.yml | 16 ++++++++++++++++ provenance/source.json | 6 +++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/workflows/split-upm.yml b/.github/workflows/split-upm.yml index 794c593..f34c6e6 100644 --- a/.github/workflows/split-upm.yml +++ b/.github/workflows/split-upm.yml @@ -82,6 +82,22 @@ jobs: native/generate-notices.sh git diff --exit-code -- "Packages/dev.tnayuki.unterm/Third Party Notices.md" + - name: Verify pinned source provenance + env: + HARDENING_BRANCH: feat/toolkit-icon-performance-mcp + UPSTREAM_BASE_COMMIT: ead1391bd38532eebfb9f13f10064eddc372b769 + run: | + jq -e \ + --arg hardeningBranch "$HARDENING_BRANCH" \ + --arg upstreamBaseCommit "$UPSTREAM_BASE_COMMIT" \ + '.schemaVersion == 1 and + .source.repository == "https://github.com/tnayuki/Unity-Unterm" and + .source.commit == $upstreamBaseCommit and + .distribution.repository == "https://github.com/rankupgames/Unity-Unterm" and + .distribution.sourceBranch == "upstream/main" and + .distribution.hardeningBranch == $hardeningBranch' \ + provenance/source.json + - name: Upload SBOM and notices uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/provenance/source.json b/provenance/source.json index 95c93d9..cdabf14 100644 --- a/provenance/source.json +++ b/provenance/source.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "source": { "repository": "https://github.com/tnayuki/Unity-Unterm", - "commit": "b4d8bd2d91751e251f0efb9b72e606471d168969", + "commit": "ead1391bd38532eebfb9f13f10064eddc372b769", "packageVersion": "0.6.0", "license": "MIT", "licenseFiles": [ @@ -12,8 +12,8 @@ }, "distribution": { "repository": "https://github.com/rankupgames/Unity-Unterm", - "sourceBranch": "main", - "hardeningBranch": "feat/harden-unterm-for-toolkit", + "sourceBranch": "upstream/main", + "hardeningBranch": "feat/toolkit-icon-performance-mcp", "supportedUnity": "6000.3", "supportedEditorPlatforms": [ "macOS", From d748afa07a7fd6c229f129f77a9044be11e6e784 Mon Sep 17 00:00:00 2001 From: Miguel Lopez Date: Mon, 13 Jul 2026 20:13:50 -0400 Subject: [PATCH 5/7] refactor: remove Claude downloader and restore terminal icon --- .../dev.tnayuki.unterm/Editor/ClaudeCode.cs | 19 +- .../Editor/UntermClaudeInstaller.cs | 426 +++--------------- .../Editor/UntermSettingsProvider.cs | 129 +----- .../dev.tnayuki.unterm/Editor/UntermWindow.cs | 2 + .../Editor/UntermWindowTitle.cs | 32 +- Packages/dev.tnayuki.unterm/README.md | 12 +- .../Tests/Editor/ClaudeInstallerTests.cs | 134 +----- README.md | 15 +- provenance/source.json | 13 - 9 files changed, 132 insertions(+), 650 deletions(-) diff --git a/Packages/dev.tnayuki.unterm/Editor/ClaudeCode.cs b/Packages/dev.tnayuki.unterm/Editor/ClaudeCode.cs index f9a6312..2c2527c 100644 --- a/Packages/dev.tnayuki.unterm/Editor/ClaudeCode.cs +++ b/Packages/dev.tnayuki.unterm/Editor/ClaudeCode.cs @@ -3,16 +3,10 @@ namespace Unterm.Editor { /// - /// Gates the "Window/Unterm/Claude Code" entry on Unterm's own managed Claude Code - /// engine binary: the item is enabled only once the binary has been downloaded - /// (see and the "Preferences > Unterm" page, - /// ), and selecting it opens the agent panel - /// (). - /// - /// Unterm deliberately drives only the engine it manages, not whatever claude - /// the user may have installed, so it always runs a known-good binary. The managed - /// path is handed to the native agent at spawn (see ), so - /// "available" is exactly "what gets launched". + /// Gates the "Window/Unterm/Claude Code" entry on an existing Claude Code + /// executable discovered from explicit configuration, PATH, common user install + /// locations, or a legacy managed install. Unterm never downloads or updates it. + /// Selecting the enabled item opens the agent panel (). /// /// Unity has no supported API to add/remove a menu item at runtime, so the entry is /// a static [MenuItem] whose validate callback greys it out until the binary @@ -22,9 +16,8 @@ internal static class ClaudeCode { private const string MenuPath = "Window/Unterm/Claude Code"; - /// The absolute path to the managed `claude` binary, passed to the native agent - /// at spawn (). "" until it has been - /// downloaded via the Preferences page. + /// The absolute path to the discovered `claude` binary, passed to the native + /// agent at spawn (), or "". internal static string ClaudePath => UntermClaudeInstaller.InstalledBinaryPath(); #if UNITY_EDITOR_OSX || UNITY_EDITOR_WIN diff --git a/Packages/dev.tnayuki.unterm/Editor/UntermClaudeInstaller.cs b/Packages/dev.tnayuki.unterm/Editor/UntermClaudeInstaller.cs index 89427ee..01523e8 100644 --- a/Packages/dev.tnayuki.unterm/Editor/UntermClaudeInstaller.cs +++ b/Packages/dev.tnayuki.unterm/Editor/UntermClaudeInstaller.cs @@ -1,94 +1,17 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; -using System.IO.Compression; -using System.Net; -using System.Runtime.InteropServices; -using System.Security.Cryptography; -using UnityEngine; -using Debug = UnityEngine.Debug; namespace Unterm.Editor { /// - /// Downloads Anthropic's official standalone Claude Code engine binary from the - /// npm registry into a per-user (not per-project) managed directory, so Unterm - /// works even when the user hasn't installed claude themselves. - /// - /// We download rather than bundle on purpose: claude-code is "Copyright Anthropic - /// PBC. All rights reserved." with no redistribution grant, so shipping it inside - /// this package would be redistribution. Fetching it from Anthropic's official - /// registry at the user's request is the same path npm install (and Zed's - /// Claude Code integration) take — no redistribution happens. - /// - /// The binary is the platform package @anthropic-ai/claude-agent-sdk-<rid> - /// — a Bun-compiled, self-contained native executable (~214MB) that needs no Node. - /// The SDK package is pinned to a version tested with the native control driver. - /// Each supported platform has a committed SHA-512 integrity value, and extraction - /// accepts only the four-file layout published for that exact release. + /// Locates an existing Claude Code executable for the optional agent panel. + /// The historical class name is retained to avoid unnecessary package churn; + /// Unterm no longer downloads, installs, or updates Claude Code. /// internal static class UntermClaudeInstaller { - private const string Scope = "@anthropic-ai"; - private const string BasePackage = "claude-agent-sdk"; - private const long MaxArchiveBytes = 256L * 1024L * 1024L; - private const long MaxBinaryBytes = 230L * 1024L * 1024L; - private const long MaxMetadataBytes = 1024L * 1024L; - - /// - /// SDK 0.3.183 embeds Claude Code 2.1.183. It was published on 2026-06-18, - /// more than seven days before this pin was reviewed on 2026-07-13. - /// - internal const string PinnedVersion = "0.3.183"; - - // WebClient (System.dll, always referenced — unlike System.Net.Http, whose - // availability depends on the API compatibility level) follows redirects to - // the registry CDN and lets us stream OpenRead() ourselves for progress. - private static WebClient NewClient() - { - var wc = new WebClient(); - wc.Headers.Add(HttpRequestHeader.UserAgent, "Unterm"); - return wc; - } - - /// The version that is actually usable right now — i.e. what the agent panel - /// will launch — or "" if the reviewed version is not installed. Unreviewed - /// sibling versions are never selected merely because they sort newer. - internal static string InstalledVersion() - { - try - { - return File.Exists(BinaryPath(PinnedVersion)) ? PinnedVersion : ""; - } - catch { return ""; } - } - - /// Absolute path to the installed binary the agent panel will launch, or "". - internal static string InstalledBinaryPath() - { - string v = InstalledVersion(); - return string.IsNullOrEmpty(v) ? "" : BinaryPath(v); - } - - // Managed install root, keyed only on the OS user (NOT the project): one - // download is shared across every Unity project. Deliberately not - // Application.persistentDataPath, which is Company/Product (i.e. per-project). - internal static string ManagedRoot() - { - string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); -#if UNITY_EDITOR_WIN - string baseDir = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - if (string.IsNullOrEmpty(baseDir)) baseDir = Path.Combine(home, "AppData", "Local"); - return Path.Combine(baseDir, "dev.tnayuki.unterm", "claude"); -#elif UNITY_EDITOR_OSX - return Path.Combine(home, "Library", "Application Support", "dev.tnayuki.unterm", "claude"); -#else - string xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); - string baseDir = string.IsNullOrEmpty(xdg) ? Path.Combine(home, ".local", "share") : xdg; - return Path.Combine(baseDir, "dev.tnayuki.unterm", "claude"); -#endif - } + private const string LegacyPinnedVersion = "0.3.183"; internal static string BinaryName => #if UNITY_EDITOR_WIN @@ -97,315 +20,104 @@ internal static string ManagedRoot() "claude"; #endif - // Absolute path the binary would live at for a given version (may not exist). - private static string BinaryPath(string version) => - Path.Combine(ManagedRoot(), version, BinaryName); - - // The npm RID for the platform package: -, matching Anthropic's - // optionalDependencies (darwin-arm64, win32-x64, linux-arm64, ...). Unity - // Editor on linux is glibc, so the non-musl variant is correct. - private static string Rid() + /// Returns an existing Claude executable path, or an empty string. + internal static string InstalledBinaryPath() { - string cpu = RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "arm64" : "x64"; + var seen = new HashSet( #if UNITY_EDITOR_WIN - return "win32-" + cpu; -#elif UNITY_EDITOR_OSX - return "darwin-" + cpu; + StringComparer.OrdinalIgnoreCase #else - return "linux-" + cpu; + StringComparer.Ordinal #endif - } - - /// Download and install the pinned claude binary. Runs on a caller's background - /// thread; is invoked with (bytesDownloaded, - /// totalBytes); totalBytes is 0 when the server sends no Content-Length. - /// Returns null on success, or an error message on failure. - internal static string Download(Action onProgress) - { - string tmpDir = null; - try - { - string rid = Rid(); - string pkgName = BasePackage + "-" + rid; // claude-agent-sdk-darwin-arm64 - string expectedIntegrity = ExpectedIntegrity(rid); - if (string.IsNullOrEmpty(expectedIntegrity)) - return $"unsupported Claude Code platform: {rid}"; - - // 1. Build the immutable npm tarball URL for the reviewed version. - string version = PinnedVersion; - string tarball = $"https://registry.npmjs.org/{Scope}/{pkgName}/-/{pkgName}-{version}.tgz"; - - // 2. Download to a temp dir, hashing the bytes as they stream in. - tmpDir = Path.Combine(ManagedRoot(), ".tmp-" + version + "-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(tmpDir); - string tgzPath = Path.Combine(tmpDir, "pkg.tgz"); - string sha512 = DownloadFile(tarball, tgzPath, onProgress); - - // 3. Verification is mandatory; there is no unverified install path. - string want = expectedIntegrity.Substring("sha512-".Length); - if (!string.Equals(want, sha512, StringComparison.Ordinal)) - return $"integrity check failed for {pkgName}@{version}"; - - // 4. Validate the complete archive layout, then stage package/. - string staged = Path.Combine(tmpDir, BinaryName); - string extractionError = ExtractBinary(tgzPath, staged); - if (extractionError != null) - return $"invalid archive for {pkgName}@{version}: {extractionError}"; - -#if !UNITY_EDITOR_WIN - Chmod755(staged); -#endif - // 5. Move into // (replace any partial install there). - string destDir = Path.Combine(ManagedRoot(), version); - Directory.CreateDirectory(destDir); - string destBin = Path.Combine(destDir, BinaryName); - try { if (File.Exists(destBin)) File.Delete(destBin); } catch { /* in use: best effort */ } - File.Move(staged, destBin); - - CleanupOtherVersions(version); - return null; - } - catch (Exception e) - { - return e.Message; - } - finally - { - try { if (tmpDir != null && Directory.Exists(tmpDir)) Directory.Delete(tmpDir, true); } - catch { /* leftover temp: harmless, cleaned on next install */ } - } - } - - // Stream a URL to disk, reporting (bytesRead, totalBytes) and returning the - // base64 SHA-512 of the bytes (to match npm's dist.integrity). - private static string DownloadFile(string url, string dest, Action onProgress) - { - if (!Uri.TryCreate(url, UriKind.Absolute, out Uri uri) || uri.Scheme != Uri.UriSchemeHttps || - !string.Equals(uri.Host, "registry.npmjs.org", StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException("Claude Code download URL is not the approved npm registry"); - - using var wc = NewClient(); - using var src = wc.OpenRead(url); - long.TryParse(wc.ResponseHeaders?[HttpResponseHeader.ContentLength], out long total); - if (total > MaxArchiveBytes) - throw new InvalidDataException($"archive exceeds {MaxArchiveBytes} bytes"); - - using var dst = new FileStream(dest, FileMode.Create, FileAccess.Write, FileShare.None); - using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA512); - - var buf = new byte[1 << 16]; - long read = 0; - int n; - while ((n = src.Read(buf, 0, buf.Length)) > 0) - { - dst.Write(buf, 0, n); - hash.AppendData(buf, 0, n); - read += n; - if (read > MaxArchiveBytes) - throw new InvalidDataException($"archive exceeds {MaxArchiveBytes} bytes"); - onProgress?.Invoke(read, total); - } - return Convert.ToBase64String(hash.GetHashAndReset()); - } - - // Minimal tar-over-gzip reader for the reviewed four-file npm layout. Every - // entry is validated before the staged executable becomes visible. - internal static string ExtractBinary(string tgzPath, string destBin) - { - using var fs = new FileStream(tgzPath, FileMode.Open, FileAccess.Read); - using var gz = new GZipStream(fs, CompressionMode.Decompress); - - string staged = destBin + ".extracting"; - if (File.Exists(staged)) File.Delete(staged); - var expected = new HashSet(StringComparer.Ordinal) - { - "package/" + BinaryName, - "package/package.json", - "package/LICENSE.md", - "package/README.md", - }; - var seen = new HashSet(StringComparer.Ordinal); - var header = new byte[512]; - bool endMarker = false; - try - { - while (ReadExact(gz, header, 512)) - { - if (IsAllZero(header)) - { - endMarker = true; - break; - } - - string name = ParseString(header, 0, 100); - if (!IsSafeArchivePath(name)) return "unsafe archive path: " + name; - if (!expected.Contains(name)) return "unexpected archive entry: " + name; - if (!seen.Add(name)) return "duplicate archive entry: " + name; - - char typeflag = (char)header[156]; - if (typeflag != '0' && typeflag != '\0') - return $"non-regular archive entry rejected: {name} (type {typeflag})"; + ); - long size = ParseOctal(header, 124, 12); - long maxSize = name == "package/" + BinaryName ? MaxBinaryBytes : MaxMetadataBytes; - if (size < 0 || size > maxSize) return $"archive entry is too large: {name}"; - long padding = (512 - (size % 512)) % 512; + string configured = ExistingPath(Environment.GetEnvironmentVariable("UNTERM_CLAUDE_PATH")); + if (!string.IsNullOrEmpty(configured)) return configured; - if (name == "package/" + BinaryName) - { - using (var outFs = new FileStream(staged, FileMode.CreateNew, FileAccess.Write, FileShare.None)) - CopyN(gz, outFs, size); - } - else - { - Skip(gz, size); - } - Skip(gz, padding); - } + string fromPath = FindOnPath(Environment.GetEnvironmentVariable("PATH")); + if (!string.IsNullOrEmpty(fromPath)) return fromPath; - if (!endMarker) return "missing tar end marker"; - if (!seen.SetEquals(expected)) return "archive layout is incomplete"; - if (!File.Exists(staged)) return $"archive does not contain package/{BinaryName}"; - if (File.Exists(destBin)) File.Delete(destBin); - File.Move(staged, destBin); - return null; - } - finally + foreach (string candidate in CommonInstallPaths()) { - if (File.Exists(staged)) File.Delete(staged); + string resolved = ExistingPath(candidate); + if (!string.IsNullOrEmpty(resolved) && seen.Add(resolved)) return resolved; } - } - - private static bool IsSafeArchivePath(string name) - { - if (string.IsNullOrEmpty(name) || name.StartsWith("/", StringComparison.Ordinal) || name.Contains("\\")) - return false; - string[] parts = name.Split('/'); - foreach (string part in parts) - if (string.IsNullOrEmpty(part) || part == "." || part == ".." || part.Contains(":")) - return false; - return true; - } - private static string ExpectedIntegrity(string rid) - { - switch (rid) - { - case "darwin-arm64": - return "sha512-o/+sxwKgXuw6RG5cERWjvcvL1CDSPe/TaXMhax+dq+V4lDOI5iTqg3y5Wfb6dL3xlWoTA2OhWowDQllKbE04LQ=="; - case "darwin-x64": - return "sha512-V7Cf8JeD5EPf4MPomFUlEblCIQI0wg+aWdOSqvfMsDmCBEHljd52CQ3a7W263oVt6I7QUfRTpX2KNvdma56rDA=="; - case "win32-x64": - return "sha512-h/XzbrSmXGroTk/FYKR6J4/8G9vDb1HUUUeNXeBGqGW1kppIiWPJKLRzjtSe0brVjADOKOT6tE5IHK0mV/1gBw=="; - default: - return null; - } + // Keep an already-installed legacy managed binary usable, but never fetch + // or update it. This avoids breaking users who installed it in an older build. + return ExistingPath(Path.Combine(LegacyManagedRoot(), LegacyPinnedVersion, BinaryName)); } - // Delete sibling version dirs so an Update reclaims the ~214MB of the old one. - // A version still in use (a running claude holds the file open) only blocks - // deletion on Windows, where it is silently skipped. - private static void CleanupOtherVersions(string keep) + internal static string FindOnPath(string pathValue) { - try + if (string.IsNullOrWhiteSpace(pathValue)) return string.Empty; + foreach (string rawDirectory in pathValue.Split(Path.PathSeparator)) { - foreach (var dir in Directory.GetDirectories(ManagedRoot())) - { - string name = Path.GetFileName(dir); - if (name == keep || name.StartsWith(".")) continue; - try { Directory.Delete(dir, true); } catch { /* in use: leave it */ } - } + string directory = rawDirectory.Trim().Trim('"'); + if (string.IsNullOrEmpty(directory)) continue; + string resolved = ExistingPath(Path.Combine(directory, BinaryName)); + if (!string.IsNullOrEmpty(resolved)) return resolved; } - catch { /* root vanished: nothing to clean */ } + return string.Empty; } -#if !UNITY_EDITOR_WIN - private static void Chmod755(string path) + private static string ExistingPath(string candidate) { + if (string.IsNullOrWhiteSpace(candidate)) return string.Empty; try { - using var p = Process.Start(new ProcessStartInfo - { - FileName = "/bin/chmod", - Arguments = "755 \"" + path + "\"", - UseShellExecute = false, - CreateNoWindow = true, - }); - p?.WaitForExit(5000); - } - catch (Exception e) - { - Debug.LogWarning("[Unterm] chmod on claude binary failed: " + e.Message); + string fullPath = Path.GetFullPath(Environment.ExpandEnvironmentVariables(candidate)); + return File.Exists(fullPath) ? fullPath : string.Empty; } - } -#endif - - // --- tar primitives ---------------------------------------------------- - - private static bool ReadExact(Stream s, byte[] buf, int count) - { - int off = 0; - while (off < count) + catch { - int n = s.Read(buf, off, count - off); - if (n <= 0) return off == 0 ? false : throw new EndOfStreamException("truncated tar"); - off += n; + return string.Empty; } - return true; } - private static bool IsAllZero(byte[] buf) + private static IEnumerable CommonInstallPaths() { - foreach (var b in buf) if (b != 0) return false; - return true; - } - - private static string ParseString(byte[] buf, int off, int len) - { - int end = off; - while (end < off + len && buf[end] != 0) end++; - return System.Text.Encoding.ASCII.GetString(buf, off, end - off); - } - - private static long ParseOctal(byte[] buf, int off, int len) - { - // GNU base-256 encoding for large sizes sets the high bit of the first byte. - if ((buf[off] & 0x80) != 0) + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); +#if UNITY_EDITOR_WIN + string local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (!string.IsNullOrEmpty(local)) + yield return Path.Combine(local, "Programs", "claude", BinaryName); + if (!string.IsNullOrEmpty(home)) + yield return Path.Combine(home, ".local", "bin", BinaryName); +#elif UNITY_EDITOR_OSX + if (!string.IsNullOrEmpty(home)) { - long v = buf[off] & 0x7f; - for (int i = 1; i < len; i++) v = (v << 8) | buf[off + i]; - return v; + yield return Path.Combine(home, ".local", "bin", BinaryName); + yield return Path.Combine(home, ".bun", "bin", BinaryName); + yield return Path.Combine(home, ".npm-global", "bin", BinaryName); } - int p = off, e = off + len; - while (p < e && (buf[p] == ' ' || buf[p] == 0)) p++; - long val = 0; - while (p < e && buf[p] >= '0' && buf[p] <= '7') { val = val * 8 + (buf[p] - '0'); p++; } - return val; - } - - private static void CopyN(Stream src, Stream dst, long count) - { - var buf = new byte[1 << 16]; - while (count > 0) + yield return "/opt/homebrew/bin/claude"; + yield return "/usr/local/bin/claude"; +#else + if (!string.IsNullOrEmpty(home)) { - int want = (int)Math.Min(buf.Length, count); - int n = src.Read(buf, 0, want); - if (n <= 0) throw new EndOfStreamException("truncated tar entry"); - dst.Write(buf, 0, n); - count -= n; + yield return Path.Combine(home, ".local", "bin", BinaryName); + yield return Path.Combine(home, ".bun", "bin", BinaryName); + yield return Path.Combine(home, ".npm-global", "bin", BinaryName); } + yield return "/usr/local/bin/claude"; +#endif } - private static void Skip(Stream src, long count) + private static string LegacyManagedRoot() { - var buf = new byte[1 << 16]; - while (count > 0) - { - int want = (int)Math.Min(buf.Length, count); - int n = src.Read(buf, 0, want); - if (n <= 0) throw new EndOfStreamException("truncated tar entry"); - count -= n; - } + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); +#if UNITY_EDITOR_WIN + string baseDir = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrEmpty(baseDir)) baseDir = Path.Combine(home, "AppData", "Local"); + return Path.Combine(baseDir, "dev.tnayuki.unterm", "claude"); +#elif UNITY_EDITOR_OSX + return Path.Combine(home, "Library", "Application Support", "dev.tnayuki.unterm", "claude"); +#else + string xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); + string baseDir = string.IsNullOrEmpty(xdg) ? Path.Combine(home, ".local", "share") : xdg; + return Path.Combine(baseDir, "dev.tnayuki.unterm", "claude"); +#endif } } } diff --git a/Packages/dev.tnayuki.unterm/Editor/UntermSettingsProvider.cs b/Packages/dev.tnayuki.unterm/Editor/UntermSettingsProvider.cs index 3d8302c..4717ae8 100644 --- a/Packages/dev.tnayuki.unterm/Editor/UntermSettingsProvider.cs +++ b/Packages/dev.tnayuki.unterm/Editor/UntermSettingsProvider.cs @@ -1,29 +1,16 @@ using System.Collections.Generic; -using System.Threading; using UnityEditor; using UnityEngine; namespace Unterm.Editor { /// - /// "Preferences > Unterm" page. Its job is to download Anthropic's standalone - /// engine binary with a button (see ) and show - /// the active and pinned versions, the resolved binary path, and live download progress. - /// Once the binary lands, the "Window/Unterm/Claude Code" menu enables on its own — - /// its validate callback checks File.Exists live (see ). - /// - /// The download runs on a background thread; the page polls a few progress fields - /// and repaints itself while it is in flight. + /// "Preferences > Unterm" page for editor, debugger, and project-local MCP + /// controls. Claude Code is optional and discovered from an existing installation; + /// Unterm does not download or manage it from this page. /// internal static class UntermSettingsProvider { - private static volatile bool s_busy; - private static long s_downloaded; // bytes pulled so far (this run) - private static long s_total; // total bytes, or 0 if unknown - private static string s_message; // last success / error line - private static bool s_failed; - private static EditorWindow s_repaintTarget; - [SettingsProvider] public static SettingsProvider Create() { @@ -33,7 +20,7 @@ public static SettingsProvider Create() guiHandler = _ => OnGui(), keywords = new HashSet { - "unterm", "claude", "claude code", "agent", "terminal", "download", + "unterm", "claude", "claude code", "agent", "terminal", "code editor", "undo", "history", "sound", "notify", "notification", "chime", "debug", "debugger", "breakpoint", "extension", "extensions", "open", "unity", "mcp", "tools", "security", "approval", @@ -43,43 +30,6 @@ public static SettingsProvider Create() private static void OnGui() { - EditorGUILayout.Space(); - EditorGUILayout.LabelField("Claude Code", EditorStyles.boldLabel); - EditorGUILayout.HelpBox( - "Unterm's agent panel drives Anthropic's standalone Claude Code engine — no Node " + - "required. If you haven't installed `claude` yourself, download it here. The binary " + - "(~214 MB) is fetched from Anthropic's official npm registry into a per-user folder " + - "shared by all your Unity projects, and you sign in with your own `claude login`.", - MessageType.Info); - - string active = UntermClaudeInstaller.InstalledVersion(); - string resolved = ClaudeCode.ClaudePath; - EditorGUILayout.LabelField("Active version", - string.IsNullOrEmpty(active) ? "(none — download required)" : active); - EditorGUILayout.LabelField("Pinned version", UntermClaudeInstaller.PinnedVersion); - using (new EditorGUI.DisabledScope(true)) - EditorGUILayout.TextField("Binary path", string.IsNullOrEmpty(resolved) ? "(not found)" : resolved); - - EditorGUILayout.Space(); - - if (s_busy) - { - long got = s_downloaded, total = s_total; - float frac = total > 0 ? (float)((double)got / total) : 0f; - string label = total > 0 - ? $"Downloading… {Mb(got):0.0} / {Mb(total):0.0} MB ({Mathf.RoundToInt(frac * 100f)}%)" - : $"Downloading… {Mb(got):0.0} MB"; - var rect = EditorGUILayout.GetControlRect(false, 20f); - EditorGUI.ProgressBar(rect, frac, label); - } - else - { - DrawAction(); - } - - if (!string.IsNullOrEmpty(s_message)) - EditorGUILayout.HelpBox(s_message, s_failed ? MessageType.Error : MessageType.Info); - EditorGUILayout.Space(); EditorGUILayout.LabelField("Code Editor", EditorStyles.boldLabel); int curLimit = UntermCodeEditorPrefs.UndoLimit; @@ -189,76 +139,5 @@ private static void OnGui() "It runs unattended only when Allow Dangerous and the separate opt-in above are both enabled.", MessageType.Error); } - - private static void DrawAction() - { - string installed = UntermClaudeInstaller.InstalledVersion(); - bool updateAvailable = !string.IsNullOrEmpty(installed) && - installed != UntermClaudeInstaller.PinnedVersion; - - if (string.IsNullOrEmpty(installed)) - { - if (GUILayout.Button("Download Claude Code")) StartDownload(); - } - else if (updateAvailable) - { - EditorGUILayout.LabelField("Status", $"Installed {installed} — approved version is {UntermClaudeInstaller.PinnedVersion}"); - if (GUILayout.Button($"Install {UntermClaudeInstaller.PinnedVersion}")) StartDownload(); - } - else - { - EditorGUILayout.LabelField("Status", $"Installed ({installed})"); - if (GUILayout.Button("Reinstall pinned version")) StartDownload(); - } - } - - private static void StartDownload() - { - if (s_busy) return; - s_busy = true; - s_downloaded = 0; - s_total = 0; - s_message = null; - s_failed = false; - s_repaintTarget = EditorWindow.focusedWindow; // the Preferences window - EditorApplication.update += RepaintWhileBusy; - - var thread = new Thread(() => - { - string err = UntermClaudeInstaller.Download((got, total) => - { - s_downloaded = got; - s_total = total; - }); - EditorApplication.delayCall += () => - { - s_busy = false; - EditorApplication.update -= RepaintWhileBusy; - if (err == null) - { - s_failed = false; - string v = UntermClaudeInstaller.InstalledVersion(); - s_message = $"Installed Claude Code {v}. The menu is now enabled."; - // The menu's validate checks File.Exists live, so it enables on - // its own; nothing else to refresh. - } - else - { - s_failed = true; - s_message = "Download failed: " + err; - } - s_repaintTarget?.Repaint(); - }; - }) - { - IsBackground = true, - Name = "UntermClaudeDownload", - }; - thread.Start(); - } - - private static void RepaintWhileBusy() => s_repaintTarget?.Repaint(); - - private static double Mb(long bytes) => bytes / (1024.0 * 1024.0); } } diff --git a/Packages/dev.tnayuki.unterm/Editor/UntermWindow.cs b/Packages/dev.tnayuki.unterm/Editor/UntermWindow.cs index 78de21c..c32be33 100644 --- a/Packages/dev.tnayuki.unterm/Editor/UntermWindow.cs +++ b/Packages/dev.tnayuki.unterm/Editor/UntermWindow.cs @@ -268,6 +268,8 @@ internal static string PluginPath private void OnEnable() { s_reloading = false; + string restoredTitle = string.IsNullOrEmpty(titleContent?.text) ? "Terminal" : titleContent.text; + titleContent = UntermWindowTitle.Create(restoredTitle, UntermWindowTitle.TerminalIcon, titleContent); wantsMouseMove = false; _sentW = _sentH = 0; _sentPpp = 0f; diff --git a/Packages/dev.tnayuki.unterm/Editor/UntermWindowTitle.cs b/Packages/dev.tnayuki.unterm/Editor/UntermWindowTitle.cs index 1d495a0..178afcb 100644 --- a/Packages/dev.tnayuki.unterm/Editor/UntermWindowTitle.cs +++ b/Packages/dev.tnayuki.unterm/Editor/UntermWindowTitle.cs @@ -15,17 +15,35 @@ internal static class UntermWindowTitle internal static GUIContent Create(string text, string iconName, GUIContent current = null) { - Texture icon = current?.image; - if (icon == null && !string.IsNullOrEmpty(iconName)) + Texture icon = null; + if (!string.IsNullOrEmpty(iconName)) { - if (!Icons.TryGetValue(iconName, out icon) || icon == null) - { - icon = EditorGUIUtility.IconContent(iconName)?.image; - if (icon != null) Icons[iconName] = icon; - } + string themedIconName = EditorGUIUtility.isProSkin && !iconName.StartsWith("d_") + ? "d_" + iconName + : iconName; + icon = ResolveIcon(themedIconName); + if (icon == null && themedIconName != iconName) + icon = ResolveIcon(iconName); } + // Unity may restore a generic serialized image before OnEnable. Prefer + // the named tool icon above, using the restored image only as a final + // fallback when the active Unity version does not expose that icon. + icon ??= current?.image; return new GUIContent(text ?? string.Empty, icon, current?.tooltip ?? string.Empty); } + + private static Texture ResolveIcon(string iconName) + { + if (!Icons.TryGetValue(iconName, out Texture icon) || icon == null) + { + icon = EditorGUIUtility.IconContent(iconName)?.image; + if (icon != null) + { + Icons[iconName] = icon; + } + } + return icon; + } } } diff --git a/Packages/dev.tnayuki.unterm/README.md b/Packages/dev.tnayuki.unterm/README.md index faecf9c..10c4e55 100644 --- a/Packages/dev.tnayuki.unterm/README.md +++ b/Packages/dev.tnayuki.unterm/README.md @@ -32,14 +32,14 @@ start in the project root. Unterm has an in-Editor Claude Code agent panel — a transcript and composer that drive Anthropic's standalone Claude Code engine in-process, no Node required. -1. Open **Preferences ▸ Unterm** and click **Download Claude Code**. A reviewed, - pinned engine release is fetched from Anthropic's official npm registry into a - per-user folder shared by all your projects. Its archive is size-bounded, - layout-validated, and verified against a platform-specific SHA-512 digest. +1. Install and manage the `claude` executable outside Unity. Unterm does not + download or update Claude Code; it passively discovers an existing executable + from `UNTERM_CLAUDE_PATH`, `PATH`, common user install locations, or a legacy + Unterm-managed install. 2. Sign in with your own Anthropic account: run `claude login` (or type `/login` in the panel, which opens a terminal for the browser sign-in). 3. Open the panel from **Window ▸ Unterm ▸ Claude Code**. The menu item stays - disabled until the engine has been downloaded. + disabled until an existing executable is found. ## Code editor @@ -60,7 +60,7 @@ and Allow Dangerous policies can permit known actions unattended. Requests outside the selected policy are denied in batch mode, and unclassified tools never auto-run. Arbitrary C# remains dangerous and runs unattended only with both Allow Dangerous and its separate full-machine-access confirmation. Claude -permission-bypass modes are rejected, and the managed Claude process receives +permission-bypass modes are rejected, and the discovered Claude process receives an explicit environment allowlist. ## Platform diff --git a/Packages/dev.tnayuki.unterm/Tests/Editor/ClaudeInstallerTests.cs b/Packages/dev.tnayuki.unterm/Tests/Editor/ClaudeInstallerTests.cs index deef423..0f4f414 100644 --- a/Packages/dev.tnayuki.unterm/Tests/Editor/ClaudeInstallerTests.cs +++ b/Packages/dev.tnayuki.unterm/Tests/Editor/ClaudeInstallerTests.cs @@ -1,14 +1,11 @@ using System; -using System.Collections.Generic; using System.IO; -using System.IO.Compression; -using System.Text; using NUnit.Framework; using Unterm.Editor; namespace Unterm.Editor.Tests { - /// Archive-boundary tests for the pinned Claude Code installer. + /// Focused tests for passive Claude executable discovery. public class ClaudeInstallerTests { private string _root; @@ -16,7 +13,7 @@ public class ClaudeInstallerTests [SetUp] public void SetUp() { - _root = Path.Combine(Path.GetTempPath(), "unterm-installer-tests-" + Guid.NewGuid().ToString("N")); + _root = Path.Combine(Path.GetTempPath(), "unterm-claude-locator-tests-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_root); } @@ -27,133 +24,28 @@ public void TearDown() } [Test] - public void ExtractBinary_AcceptsReviewedLayout() + public void FindOnPath_ReturnsExistingClaudeExecutable() { - string archive = WriteArchive(ValidEntries()); - string destination = Path.Combine(_root, UntermClaudeInstaller.BinaryName); + string binary = Path.Combine(_root, UntermClaudeInstaller.BinaryName); + File.WriteAllText(binary, "test executable"); - Assert.IsNull(UntermClaudeInstaller.ExtractBinary(archive, destination)); - Assert.AreEqual("reviewed-binary", File.ReadAllText(destination)); + Assert.AreEqual(Path.GetFullPath(binary), UntermClaudeInstaller.FindOnPath(_root)); } [Test] - public void ExtractBinary_RejectsTraversalAndRemovesStagingFile() + public void FindOnPath_SkipsMissingDirectories() { - var entries = ValidEntries(); - entries.Add(new Entry("package/../escape", '0', "bad")); - string archive = WriteArchive(entries); - string destination = Path.Combine(_root, UntermClaudeInstaller.BinaryName); + string binary = Path.Combine(_root, UntermClaudeInstaller.BinaryName); + File.WriteAllText(binary, "test executable"); + string pathValue = Path.Combine(_root, "missing") + Path.PathSeparator + _root; - StringAssert.Contains("unsafe archive path", UntermClaudeInstaller.ExtractBinary(archive, destination)); - Assert.IsFalse(File.Exists(destination)); - Assert.IsFalse(File.Exists(destination + ".extracting")); + Assert.AreEqual(Path.GetFullPath(binary), UntermClaudeInstaller.FindOnPath(pathValue)); } [Test] - public void ExtractBinary_RejectsSymlink() + public void FindOnPath_ReturnsEmptyWhenClaudeIsMissing() { - var entries = ValidEntries(); - entries[0] = new Entry("package/" + UntermClaudeInstaller.BinaryName, '2', ""); - string archive = WriteArchive(entries); - - StringAssert.Contains("non-regular", UntermClaudeInstaller.ExtractBinary( - archive, Path.Combine(_root, UntermClaudeInstaller.BinaryName))); - } - - [Test] - public void ExtractBinary_RejectsUnexpectedLayout() - { - var entries = ValidEntries(); - entries.Add(new Entry("package/postinstall.js", '0', "unexpected")); - string archive = WriteArchive(entries); - - StringAssert.Contains("unexpected archive entry", UntermClaudeInstaller.ExtractBinary( - archive, Path.Combine(_root, UntermClaudeInstaller.BinaryName))); - } - - [Test] - public void ExtractBinary_RejectsOversizedMetadataBeforeReadingPayload() - { - var entries = ValidEntries(); - entries[3] = new Entry("package/README.md", '0', "", 2L * 1024L * 1024L); - string archive = WriteArchive(entries); - - StringAssert.Contains("too large", UntermClaudeInstaller.ExtractBinary( - archive, Path.Combine(_root, UntermClaudeInstaller.BinaryName))); - } - - private static List ValidEntries() => new List - { - new Entry("package/" + UntermClaudeInstaller.BinaryName, '0', "reviewed-binary"), - new Entry("package/package.json", '0', "{}"), - new Entry("package/LICENSE.md", '0', "license"), - new Entry("package/README.md", '0', "readme"), - }; - - private string WriteArchive(IReadOnlyList entries) - { - string path = Path.Combine(_root, "fixture.tgz"); - using (var file = new FileStream(path, FileMode.CreateNew, FileAccess.Write)) - using (var gzip = new GZipStream(file, CompressionMode.Compress)) - { - foreach (Entry entry in entries) WriteEntry(gzip, entry); - gzip.Write(new byte[1024], 0, 1024); - } - return path; - } - - private static void WriteEntry(Stream stream, Entry entry) - { - byte[] content = Encoding.UTF8.GetBytes(entry.Content); - long declaredSize = entry.DeclaredSize ?? content.Length; - var header = new byte[512]; - WriteAscii(header, 0, 100, entry.Name); - WriteOctal(header, 100, 8, 493); - WriteOctal(header, 108, 8, 0); - WriteOctal(header, 116, 8, 0); - WriteOctal(header, 124, 12, declaredSize); - WriteOctal(header, 136, 12, 0); - for (int i = 148; i < 156; i++) header[i] = 32; - header[156] = (byte)entry.TypeFlag; - WriteAscii(header, 257, 6, "ustar"); - long checksum = 0; - foreach (byte value in header) checksum += value; - WriteOctal(header, 148, 8, checksum); - stream.Write(header, 0, header.Length); - - if (entry.DeclaredSize.HasValue && entry.DeclaredSize.Value != content.Length) return; - stream.Write(content, 0, content.Length); - int padding = (int)((512 - (content.Length % 512)) % 512); - if (padding > 0) stream.Write(new byte[padding], 0, padding); - } - - private static void WriteAscii(byte[] buffer, int offset, int length, string value) - { - byte[] bytes = Encoding.ASCII.GetBytes(value); - Array.Copy(bytes, 0, buffer, offset, Math.Min(length, bytes.Length)); - } - - private static void WriteOctal(byte[] buffer, int offset, int length, long value) - { - string text = Convert.ToString(value, 8).PadLeft(length - 1, '0'); - WriteAscii(buffer, offset, length - 1, text); - buffer[offset + length - 1] = 0; - } - - private readonly struct Entry - { - public readonly string Name; - public readonly char TypeFlag; - public readonly string Content; - public readonly long? DeclaredSize; - - public Entry(string name, char typeFlag, string content, long? declaredSize = null) - { - Name = name; - TypeFlag = typeFlag; - Content = content; - DeclaredSize = declaredSize; - } + Assert.AreEqual(string.Empty, UntermClaudeInstaller.FindOnPath(_root)); } } } diff --git a/README.md b/README.md index 06e5e9a..eeb7276 100644 --- a/README.md +++ b/README.md @@ -41,15 +41,14 @@ start in the project root. Unterm has an in-Editor Claude Code agent panel — a transcript and composer that drive Anthropic's standalone Claude Code engine in-process, no Node required. -1. Open **Preferences ▸ Unterm** and click **Download Claude Code**. The reviewed, - pinned engine release is fetched from Anthropic's official npm registry into a - per-user folder shared by all your projects. The archive is size-bounded, - layout-validated, and verified against its platform-specific SHA-512 digest - before installation. +1. Install and manage the `claude` executable outside Unity. Unterm does not + download or update Claude Code; it passively discovers an existing executable + from `UNTERM_CLAUDE_PATH`, `PATH`, common user install locations, or a legacy + Unterm-managed install. 2. Sign in with your own Anthropic account: run `claude login` (or type `/login` in the panel, which opens a terminal for the browser sign-in). 3. Open the panel from **Window ▸ Unterm ▸ Claude Code**. The menu item stays - disabled until the engine has been downloaded. + disabled until an existing executable is found. ## Code editor @@ -72,7 +71,7 @@ Arbitrary C# remains dangerous and runs unattended only with both Allow Dangerou and its separate full-machine-access confirmation. Claude Code permission-bypass modes are rejected. -The managed Claude child process receives an explicit environment allowlist, so +The discovered Claude child process receives an explicit environment allowlist, so host credentials and unrelated secrets are not inherited by default. ## Platform @@ -90,7 +89,7 @@ any other platform the package contributes nothing. `native/build-macos.sh` (or `native/build-windows.ps1`) to build the native binary into the package for in-editor development. Not part of the published source. - `provenance/` — the audited upstream revision, dependency remediation, and - reviewed downloader pin. + reviewed source baseline. Feature-branch pushes, `main`, and version tags build both native platforms, generate an SBOM and notices, publish checksums and build metadata, and attest diff --git a/provenance/source.json b/provenance/source.json index cdabf14..f0cf7de 100644 --- a/provenance/source.json +++ b/provenance/source.json @@ -20,19 +20,6 @@ "Windows" ] }, - "claudeCode": { - "packageVersion": "0.3.183", - "embeddedCliVersion": "2.1.183", - "publishedAt": "2026-06-18T23:58:59.358Z", - "reviewedAt": "2026-07-13", - "minimumReleaseAgeDays": 7, - "registry": "https://registry.npmjs.org", - "integrity": { - "darwin-arm64": "sha512-o/+sxwKgXuw6RG5cERWjvcvL1CDSPe/TaXMhax+dq+V4lDOI5iTqg3y5Wfb6dL3xlWoTA2OhWowDQllKbE04LQ==", - "darwin-x64": "sha512-V7Cf8JeD5EPf4MPomFUlEblCIQI0wg+aWdOSqvfMsDmCBEHljd52CQ3a7W263oVt6I7QUfRTpX2KNvdma56rDA==", - "win32-x64": "sha512-h/XzbrSmXGroTk/FYKR6J4/8G9vDb1HUUUeNXeBGqGW1kppIiWPJKLRzjtSe0brVjADOKOT6tE5IHK0mV/1gBw==" - } - }, "rustRemediation": { "plist": "1.10.0", "quick-xml": "0.41.0", From 4799f64e81033504d2d3c20e8219ce6d83163069 Mon Sep 17 00:00:00 2001 From: Miguel Lopez Date: Mon, 13 Jul 2026 20:38:15 -0400 Subject: [PATCH 6/7] build: preserve safe Linux debugger support --- .github/workflows/split-upm.yml | 3 + Packages/dev.tnayuki.unterm/README.md | 5 + .../dev.tnayuki.unterm/Third Party Notices.md | 355 +++++++++++++++++- README.md | 5 + native/Cargo.lock | 59 +++ native/about.toml | 13 +- native/unterm/Cargo.toml | 23 +- provenance/source.json | 5 + 8 files changed, 454 insertions(+), 14 deletions(-) diff --git a/.github/workflows/split-upm.yml b/.github/workflows/split-upm.yml index f34c6e6..25e8256 100644 --- a/.github/workflows/split-upm.yml +++ b/.github/workflows/split-upm.yml @@ -65,6 +65,9 @@ jobs: - name: Verify the locked dependency graph run: cargo metadata --manifest-path native/Cargo.toml --locked --format-version 1 > /dev/null + - name: Compile-check the standalone Linux debugger + run: cargo check --manifest-path native/Cargo.toml -p unterm --bin unterm-debugger --locked + - name: Deny vulnerable and unsound Rust advisories run: cargo audit --file native/Cargo.lock diff --git a/Packages/dev.tnayuki.unterm/README.md b/Packages/dev.tnayuki.unterm/README.md index 10c4e55..e5db9d4 100644 --- a/Packages/dev.tnayuki.unterm/README.md +++ b/Packages/dev.tnayuki.unterm/README.md @@ -71,6 +71,11 @@ editor a GPU texture with no CPU copy: an IOSurface (Metal) on macOS, a shared D3D12 texture on Windows. The menu item is registered only on those editors; on any other platform the package contributes nothing. +The standalone debugger keeps an intentional Linux/X11 build path that CI +compile-checks, but the Unity package does not yet ship or register a Linux +native plugin. Wayland is deferred until its scanner dependency supports a +patched XML parser release. + The package ships prebuilt native binaries — a universal (arm64 + x86_64) `unterm.dylib` for macOS and an `unterm.dll` for Windows (x86_64). To rebuild from the Rust source, run `native/build-macos.sh` or `native/build-windows.ps1` diff --git a/Packages/dev.tnayuki.unterm/Third Party Notices.md b/Packages/dev.tnayuki.unterm/Third Party Notices.md index 75e657d..943a357 100644 --- a/Packages/dev.tnayuki.unterm/Third Party Notices.md +++ b/Packages/dev.tnayuki.unterm/Third Party Notices.md @@ -7,11 +7,11 @@ require preserving the copyright notice and license text reproduced here. This file is generated with [`cargo about`](https://github.com/EmbarkStudios/cargo-about); run `native/generate-notices.sh` from the repository root to regenerate it. -- MIT License (287) -- Apache License 2.0 (15) +- MIT License (304) +- Apache License 2.0 (16) +- ISC License (3) - zlib License (3) - Creative Commons Zero v1.0 Universal (2) -- ISC License (1) - SIL Open Font License 1.1 (1) - Ubuntu Font Licence v1.0 (1) - Unicode License v3 (1) @@ -857,6 +857,7 @@ limitations under the License. ## Apache License 2.0 +- gethostname 1.1.0 — https://codeberg.org/swsnr/gethostname.rs.git - similar 2.7.0 — https://github.com/mitsuhiko/similar ``` @@ -1745,6 +1746,47 @@ express Statement of Purpose. ## ISC License +- inotify-sys 0.1.8 — https://github.com/hannobraun/inotify-sys + +``` +Copyright (c) Hanno Braun and contributors + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. +``` + +## ISC License + +- inotify 0.9.6 — https://github.com/hannobraun/inotify + +``` +Copyright (c) Hanno Braun and contributors + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. + +``` + +## ISC License + - libloading 0.8.9 — https://github.com/nagisa/rust_libloading/ ``` @@ -1836,6 +1878,39 @@ DEALINGS IN THE SOFTWARE. ## MIT License +- percent-encoding 2.3.2 — https://github.com/servo/rust-url/ + +``` +Copyright (c) 2013-2025 The rust-url developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + - cc 1.2.67 — https://github.com/rust-lang/cc-rs - cfg-if 1.0.4 — https://github.com/rust-lang/cfg-if - filetime 0.2.29 — https://github.com/alexcrichton/filetime @@ -1943,6 +2018,33 @@ DEALINGS IN THE SOFTWARE. ## MIT License +- mio 0.8.11 — https://github.com/tokio-rs/mio + +``` +Copyright (c) 2014 Carl Lerche and other MIO contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +``` + +## MIT License + - errno 0.3.14 — https://github.com/lambda-fairy/rust-errno ``` @@ -3095,6 +3197,33 @@ DEALINGS IN THE SOFTWARE. ## MIT License +- calloop 0.13.0 — https://github.com/Smithay/calloop + +``` +Copyright (c) 2018 Victor Berger + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +``` + +## MIT License + - ttf-parser 0.25.1 — https://github.com/harfbuzz/ttf-parser ``` @@ -3156,6 +3285,39 @@ DEALINGS IN THE SOFTWARE. ## MIT License +- slab 0.4.12 — https://github.com/tokio-rs/slab + +``` +Copyright (c) 2019 Carl Lerche + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + - font-types 0.11.3 — https://github.com/googlefonts/fontations - read-fonts 0.37.0 — https://github.com/googlefonts/fontations - read-fonts 0.39.2 — https://github.com/googlefonts/fontations @@ -3558,6 +3720,33 @@ DEALINGS IN THE SOFTWARE. ## MIT License +- xkeysym 0.2.1 — https://github.com/notgull/xkeysym + +``` +Copyright (c) 2022-2023 John Nunley + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + +## MIT License + - powerfmt 0.2.0 — https://github.com/jhpratt/powerfmt ``` @@ -3905,6 +4094,73 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ## MIT License +- as-raw-xcb-connection 1.0.1 — https://github.com/psychon/as-raw-xcb-connection + +``` +Copyright 2019 as-raw-xcb-connection Contributers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- x11rb-protocol 0.13.2 — https://github.com/psychon/x11rb +- x11rb 0.13.2 — https://github.com/psychon/x11rb + +``` +Copyright 2019 x11rb Contributers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + - vello_common 0.0.9 — https://github.com/linebender/vello - vello_cpu 0.0.9 — https://github.com/linebender/vello @@ -4470,6 +4726,8 @@ SOFTWARE. - windows-targets 0.52.6 — https://github.com/microsoft/windows-rs - windows-threading 0.2.1 — https://github.com/microsoft/windows-rs - windows 0.62.2 — https://github.com/microsoft/windows-rs +- windows_x86_64_gnu 0.48.5 — https://github.com/microsoft/windows-rs +- windows_x86_64_gnu 0.52.6 — https://github.com/microsoft/windows-rs - windows_x86_64_msvc 0.48.5 — https://github.com/microsoft/windows-rs - windows_x86_64_msvc 0.52.6 — https://github.com/microsoft/windows-rs @@ -4656,6 +4914,35 @@ DEALINGS IN THE SOFTWARE. ## MIT License +- fontconfig-parser 0.5.8 — https://github.com/Riey/fontconfig-parser + +``` +MIT License + +Copyright (c) 2021 Riey + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + +## MIT License + - rustc-hash 2.1.3 — https://github.com/rust-lang/rustc-hash ``` @@ -4694,6 +4981,8 @@ DEALINGS IN THE SOFTWARE. - home 0.5.12 — https://github.com/rust-lang/cargo - itoa 1.0.18 — https://github.com/dtolnay/itoa - khronos-egl 6.0.0 — https://github.com/timothee-haudebourg/khronos-egl +- linux-raw-sys 0.12.1 — https://github.com/sunfishcode/linux-raw-sys +- linux-raw-sys 0.4.15 — https://github.com/sunfishcode/linux-raw-sys - once_cell 1.21.4 — https://github.com/matklad/once_cell - pin-project-lite 0.2.17 — https://github.com/taiki-e/pin-project-lite - piper 0.2.5 — https://github.com/smol-rs/piper @@ -4702,6 +4991,7 @@ DEALINGS IN THE SOFTWARE. - quote 1.0.46 — https://github.com/dtolnay/quote - rustc-hash 1.1.0 — https://github.com/rust-lang-nursery/rustc-hash - rustix-openpty 0.2.0 — https://github.com/sunfishcode/rustix-openpty +- rustix 0.38.44 — https://github.com/bytecodealliance/rustix - rustix 1.1.4 — https://github.com/bytecodealliance/rustix - serde 1.0.228 — https://github.com/serde-rs/serde - serde_core 1.0.228 — https://github.com/serde-rs/serde @@ -4715,6 +5005,7 @@ DEALINGS IN THE SOFTWARE. - thiserror 1.0.69 — https://github.com/dtolnay/thiserror - thiserror 2.0.18 — https://github.com/dtolnay/thiserror - unicode-ident 1.0.24 — https://github.com/dtolnay/unicode-ident +- x11-dl 2.21.0 — https://github.com/AltF02/x11-rs.git - zmij 1.0.23 — https://github.com/dtolnay/zmij ``` @@ -5228,6 +5519,35 @@ SOFTWARE. ## MIT License +- roxmltree 0.20.0 — https://github.com/RazrFalcon/roxmltree + +``` +The MIT License (MIT) + +Copyright (c) 2018 Yevhenii Reizner + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + +## MIT License + - guillotiere 0.7.0 — https://github.com/nical/guillotiere ``` @@ -5377,6 +5697,35 @@ SOFTWARE. ## MIT License +- xkbcommon-dl 0.4.2 — https://github.com/rust-windowing/xkbcommon-dl + +``` +The MIT License (MIT) + +Copyright (c) 2023 Kirill Chibisov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + +## MIT License + - version_check 0.9.5 — https://github.com/SergioBenitez/version_check ``` diff --git a/README.md b/README.md index eeb7276..d5b28d5 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,11 @@ GPU texture with no CPU copy — an IOSurface (Metal) on macOS, a shared D3D12 texture on Windows — so the menu item is registered only on those editors; on any other platform the package contributes nothing. +The standalone debugger keeps an intentional Linux/X11 build path that CI +compile-checks, but the Unity package does not yet ship or register a Linux +native plugin. Wayland is deferred until its scanner dependency supports a +patched XML parser release. + ## Repository layout - `Packages/dev.tnayuki.unterm/` — the vendorable UPM package source. Native diff --git a/native/Cargo.lock b/native/Cargo.lock index b898464..9afc109 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -165,6 +165,12 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -631,6 +637,7 @@ version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea6bf3608db949588b95b8b341ee358d0c3f95cf4dc3f53d8d76717edee87db" dependencies = [ + "bytemuck", "egui", "log", "objc2 0.6.4", @@ -930,6 +937,16 @@ dependencies = [ "slab", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -2142,6 +2159,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "phf" version = "0.11.3" @@ -3885,6 +3908,7 @@ dependencies = [ "atomic-waker", "bitflags 2.13.0", "block2 0.5.1", + "bytemuck", "calloop", "cfg_aliases", "concurrent-queue", @@ -3900,6 +3924,7 @@ dependencies = [ "objc2-foundation 0.2.2", "objc2-ui-kit 0.2.2", "orbclient", + "percent-encoding", "pin-project", "raw-window-handle", "redox_syscall 0.4.1", @@ -3912,6 +3937,8 @@ dependencies = [ "web-sys", "web-time", "windows-sys 0.52.0", + "x11-dl", + "x11rb", "xkbcommon-dl", ] @@ -3933,6 +3960,38 @@ dependencies = [ "winapi", ] +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xkbcommon-dl" version = "0.4.2" diff --git a/native/about.toml b/native/about.toml index 16bcfaa..489246e 100644 --- a/native/about.toml +++ b/native/about.toml @@ -1,7 +1,7 @@ # Config for `cargo about` — generates the third-party license notices bundled -# with the native plugin. We filter to the shipped targets (macOS + Windows) so -# the scan covers every crate that ends up in a distributed cdylib and drops -# build/dev-only and other-platform crates. +# with the native plugin. We cover the shipped editor targets (macOS + Windows) +# plus the intentionally supported standalone Linux debugger build so every +# distributed or compile-validated runtime dependency has a bundled notice. accepted = [ "MIT", "Apache-2.0", @@ -24,11 +24,12 @@ accepted = [ "Ubuntu-font-1.0", ] -# The shipped targets: macOS aarch64/x86_64 and Windows x86_64. This prunes -# Linux-only deps while still covering the Windows-only crates (windows, d3d12, -# gpu-allocator, hassle-rs, …) that go into unterm.dll. +# Shipped targets: macOS aarch64/x86_64 and Windows x86_64. Linux x86_64 is the +# CI compile-validation target for the standalone debugger; it is not yet a +# shipped Unity native plugin. targets = [ "aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-pc-windows-msvc", + "x86_64-unknown-linux-gnu", ] diff --git a/native/unterm/Cargo.toml b/native/unterm/Cargo.toml index f4e0783..b539c33 100644 --- a/native/unterm/Cargo.toml +++ b/native/unterm/Cargo.toml @@ -68,13 +68,10 @@ isolang = { version = "2.4.0", default-features = false } # Standalone debugger window (`unterm-debugger` bin / `debugger` module): its own # winit event loop + wgpu surface, so it stays live while the editor is frozen at a # breakpoint. Only entered by the bin; the Unity-loaded cdylib never calls into it. -# The debugger ships only for macOS and Windows. Disable winit/egui-winit's -# default Linux X11/Wayland backends so unused platform crates do not enter the -# audited lockfile (and the shipped dependency surface stays target-accurate). -winit = { version = "0.30", default-features = false, features = ["rwh_06"] } +# Platform-specific winit/egui-winit features are declared below so Linux keeps +# an intentional debugger path without expanding macOS/Windows builds. egui-wgpu = "0.35.0" egui = "0.35" -egui-winit = { version = "0.35", default-features = false } # Player discovery: bind the Unity multicast group with SO_REUSEPORT (shared with the # editor's own listener) — std's UdpSocket can't set the sockopt before bind. socket2 = { version = "0.5", features = ["all"] } @@ -87,6 +84,22 @@ tree-sitter-md = "0.5.3" [target.'cfg(unix)'.dependencies] libc = "0.2" +# The Unity-integrated package currently ships on macOS and Windows. Keep their +# debugger windowing dependencies free of Linux-only X11/Wayland backends. +[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies] +winit = { version = "0.30", default-features = false, features = ["rwh_06"] } +egui-winit = { version = "0.35", default-features = false } + +# Preserve the standalone debugger's Linux build intention with an explicit X11 +# backend that CI compile-checks. Wayland stays disabled until its scanner moves +# off vulnerable quick-xml 0.39.x; clipboard and link openers are also unused. +[target.'cfg(target_os = "linux")'.dependencies] +winit = { version = "0.30", default-features = false, features = [ + "rwh_06", + "x11", +] } +egui-winit = { version = "0.35", default-features = false, features = ["x11"] } + # IOSurface zero-copy path (macOS). Uses the same objc2-metal/objc2-io-surface # bindings wgpu-hal 29 builds on, so the raw MTLDevice/MTLTexture handed to # wgpu-hal matches. diff --git a/provenance/source.json b/provenance/source.json index f0cf7de..4e01ae2 100644 --- a/provenance/source.json +++ b/provenance/source.json @@ -18,6 +18,11 @@ "supportedEditorPlatforms": [ "macOS", "Windows" + ], + "debuggerCompileValidationPlatforms": [ + "macOS", + "Windows", + "Linux" ] }, "rustRemediation": { From b056990babba629c8505a345ddc5a7a2d845ad6d Mon Sep 17 00:00:00 2001 From: Miguel Lopez Date: Mon, 13 Jul 2026 20:45:26 -0400 Subject: [PATCH 7/7] ci: verify Linux debugger dependency intent --- .github/workflows/split-upm.yml | 13 +++++++++++-- Packages/dev.tnayuki.unterm/README.md | 9 +++++---- README.md | 9 +++++---- native/about.toml | 8 ++++---- native/unterm/Cargo.toml | 8 +++++--- provenance/source.json | 4 +--- 6 files changed, 31 insertions(+), 20 deletions(-) diff --git a/.github/workflows/split-upm.yml b/.github/workflows/split-upm.yml index 25e8256..cf4c653 100644 --- a/.github/workflows/split-upm.yml +++ b/.github/workflows/split-upm.yml @@ -65,8 +65,17 @@ jobs: - name: Verify the locked dependency graph run: cargo metadata --manifest-path native/Cargo.toml --locked --format-version 1 > /dev/null - - name: Compile-check the standalone Linux debugger - run: cargo check --manifest-path native/Cargo.toml -p unterm --bin unterm-debugger --locked + - name: Verify standalone Linux debugger dependencies + run: | + linux_tree="$RUNNER_TEMP/unterm-linux-features.txt" + cargo tree --manifest-path native/Cargo.toml -p unterm \ + --target x86_64-unknown-linux-gnu --edges features --invert winit --locked \ + | tee "$linux_tree" + grep -Fq 'winit feature "x11"' "$linux_tree" + if grep -Fq 'winit feature "wayland"' "$linux_tree"; then + echo "Wayland must remain disabled until wayland-scanner uses a patched quick-xml release." + exit 1 + fi - name: Deny vulnerable and unsound Rust advisories run: cargo audit --file native/Cargo.lock diff --git a/Packages/dev.tnayuki.unterm/README.md b/Packages/dev.tnayuki.unterm/README.md index e5db9d4..e69ea2d 100644 --- a/Packages/dev.tnayuki.unterm/README.md +++ b/Packages/dev.tnayuki.unterm/README.md @@ -71,10 +71,11 @@ editor a GPU texture with no CPU copy: an IOSurface (Metal) on macOS, a shared D3D12 texture on Windows. The menu item is registered only on those editors; on any other platform the package contributes nothing. -The standalone debugger keeps an intentional Linux/X11 build path that CI -compile-checks, but the Unity package does not yet ship or register a Linux -native plugin. Wayland is deferred until its scanner dependency supports a -patched XML parser release. +The standalone debugger keeps its future Linux/X11 dependency path explicit and +CI verifies that feature graph, but the native library and debugger binary still +build only on macOS and Windows because the embedded source pane uses the Unity +shared-surface renderer. Wayland is deferred until its scanner dependency +supports a patched XML parser release. The package ships prebuilt native binaries — a universal (arm64 + x86_64) `unterm.dylib` for macOS and an `unterm.dll` for Windows (x86_64). To rebuild diff --git a/README.md b/README.md index d5b28d5..2a3c227 100644 --- a/README.md +++ b/README.md @@ -81,10 +81,11 @@ GPU texture with no CPU copy — an IOSurface (Metal) on macOS, a shared D3D12 texture on Windows — so the menu item is registered only on those editors; on any other platform the package contributes nothing. -The standalone debugger keeps an intentional Linux/X11 build path that CI -compile-checks, but the Unity package does not yet ship or register a Linux -native plugin. Wayland is deferred until its scanner dependency supports a -patched XML parser release. +The standalone debugger keeps its future Linux/X11 dependency path explicit and +CI verifies that feature graph, but the native library and debugger binary still +build only on macOS and Windows because the embedded source pane uses the Unity +shared-surface renderer. Wayland is deferred until its scanner dependency +supports a patched XML parser release. ## Repository layout diff --git a/native/about.toml b/native/about.toml index 489246e..8084879 100644 --- a/native/about.toml +++ b/native/about.toml @@ -1,7 +1,7 @@ # Config for `cargo about` — generates the third-party license notices bundled # with the native plugin. We cover the shipped editor targets (macOS + Windows) -# plus the intentionally supported standalone Linux debugger build so every -# distributed or compile-validated runtime dependency has a bundled notice. +# plus the standalone debugger's intentionally retained future Linux dependency +# path so every distributed or CI-validated runtime dependency has a notice. accepted = [ "MIT", "Apache-2.0", @@ -25,8 +25,8 @@ accepted = [ ] # Shipped targets: macOS aarch64/x86_64 and Windows x86_64. Linux x86_64 is the -# CI compile-validation target for the standalone debugger; it is not yet a -# shipped Unity native plugin. +# CI dependency-validation target for the future standalone debugger port; no +# Linux binary or Unity native plugin ships yet. targets = [ "aarch64-apple-darwin", "x86_64-apple-darwin", diff --git a/native/unterm/Cargo.toml b/native/unterm/Cargo.toml index b539c33..2ad8081 100644 --- a/native/unterm/Cargo.toml +++ b/native/unterm/Cargo.toml @@ -90,9 +90,11 @@ libc = "0.2" winit = { version = "0.30", default-features = false, features = ["rwh_06"] } egui-winit = { version = "0.35", default-features = false } -# Preserve the standalone debugger's Linux build intention with an explicit X11 -# backend that CI compile-checks. Wayland stays disabled until its scanner moves -# off vulnerable quick-xml 0.39.x; clipboard and link openers are also unused. +# Preserve the standalone debugger's future Linux build path with an explicit X11 +# backend whose resolved feature graph CI verifies. The current native library and +# debugger binary still build only on macOS/Windows because their embedded source +# pane uses the Unity shared-surface renderer. Wayland stays disabled until its +# scanner moves off vulnerable quick-xml 0.39.x; clipboard/link openers are unused. [target.'cfg(target_os = "linux")'.dependencies] winit = { version = "0.30", default-features = false, features = [ "rwh_06", diff --git a/provenance/source.json b/provenance/source.json index 4e01ae2..7e116d0 100644 --- a/provenance/source.json +++ b/provenance/source.json @@ -19,9 +19,7 @@ "macOS", "Windows" ], - "debuggerCompileValidationPlatforms": [ - "macOS", - "Windows", + "debuggerDependencyValidationPlatforms": [ "Linux" ] },