diff --git a/.github/workflows/split-upm.yml b/.github/workflows/split-upm.yml
index 794c593..cf4c653 100644
--- a/.github/workflows/split-upm.yml
+++ b/.github/workflows/split-upm.yml
@@ -65,6 +65,18 @@ jobs:
- name: Verify the locked dependency graph
run: cargo metadata --manifest-path native/Cargo.toml --locked --format-version 1 > /dev/null
+ - 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
@@ -82,6 +94,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/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/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/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/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/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/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)
{
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/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..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;
@@ -131,11 +81,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,82 +98,46 @@ private static void OnGui()
UntermMcpSecurity.Disable();
UntermMcp.RefreshTools();
}
- 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);
- }
-
- 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)
+ if (!UntermMcpSecurity.Enabled)
{
- EditorGUILayout.LabelField("Status", $"Installed {installed} — approved version is {UntermClaudeInstaller.PinnedVersion}");
- if (GUILayout.Button($"Install {UntermClaudeInstaller.PinnedVersion}")) StartDownload();
+ 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;
}
- 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;
+ 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);
- var thread = new Thread(() =>
+ string policyMessage = UntermMcpSecurity.AccessPolicy switch
{
- 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",
+ 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.",
};
- thread.Start();
- }
-
- private static void RepaintWhileBusy() => s_repaintTarget?.Repaint();
+ EditorGUILayout.HelpBox(policyMessage, MessageType.Warning);
- private static double Mb(long bytes) => bytes / (1024.0 * 1024.0);
+ 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(
+ "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);
+ }
}
}
diff --git a/Packages/dev.tnayuki.unterm/Editor/UntermWindow.cs b/Packages/dev.tnayuki.unterm/Editor/UntermWindow.cs
index 348ad48..c32be33 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();
@@ -257,7 +268,12 @@ 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;
+ ResetGpuRetry();
AssemblyReloadEvents.beforeAssemblyReload += OnBeforeReload;
EditorApplication.update += OnEditorUpdate;
LoadNative();
@@ -462,6 +478,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 +535,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 +548,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 +576,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 +636,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 +683,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 +719,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..178afcb
--- /dev/null
+++ b/Packages/dev.tnayuki.unterm/Editor/UntermWindowTitle.cs
@@ -0,0 +1,49 @@
+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 = null;
+ if (!string.IsNullOrEmpty(iconName))
+ {
+ 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/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/README.md b/Packages/dev.tnayuki.unterm/README.md
index 7e6ea17..e69ea2d 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
@@ -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 discovered Claude process receives
+an explicit environment allowlist.
## Platform
@@ -67,6 +71,12 @@ 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 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
from the Rust source, run `native/build-macos.sh` or `native/build-windows.ps1`
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/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");
}
}
}
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 386f430..2a3c227 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
@@ -63,12 +62,16 @@ 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.
-
-The managed Claude child process receives an explicit environment allowlist, so
+**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 discovered Claude child process receives an explicit environment allowlist, so
host credentials and unrelated secrets are not inherited by default.
## Platform
@@ -78,6 +81,12 @@ 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 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
- `Packages/dev.tnayuki.unterm/` — the vendorable UPM package source. Native
@@ -86,7 +95,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/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..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 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 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",
@@ -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 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",
"x86_64-pc-windows-msvc",
+ "x86_64-unknown-linux-gnu",
]
diff --git a/native/unterm/Cargo.toml b/native/unterm/Cargo.toml
index f4e0783..2ad8081 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,24 @@ 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 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",
+ "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 95c93d9..7e116d0 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,27 +12,17 @@
},
"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",
"Windows"
+ ],
+ "debuggerDependencyValidationPlatforms": [
+ "Linux"
]
},
- "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",