Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added
- Long-running commands now finish faster. Test runs, package operations, profiler sampling, recording and `qa run-sequence` all advance one step per Editor update tick, and an idle Editor runs that loop slowly — measured at roughly six ticks per second, whether or not its window is in front. While one of those commands is in flight the bridge now keeps the Editor ticking at full rate and lets it settle back the moment the work finishes, which measured about 1.5× faster end to end (60 ticks: 9.4–10.4 s → 6.0–6.1 s on Unity 6000.3 / macOS). Nothing to turn on, and on an Editor version that does not expose the internal API this relies on, commands simply run at the old speed.
- Component values may now be written as JSON arrays: `"m_Center": [1, 2, 3]` alongside the existing `{"x": 1, "y": 2, "z": 3}`. Vector2/3/4, Vector2Int/Vector3Int, Quaternion, Rect, RectInt and Color all accept the short form, and a Color array may leave off alpha.
- Structured values that arrive quoted — `"[1,2,3]"` or `"{\"x\":1,\"y\":2,\"z\":3}"` instead of the JSON value itself — are now parsed instead of rejected. AI agents produce this shape often, and the previous error read as though the value were wrong rather than the quoting. Only strings that open a JSON object or array are re-read, so asset paths, object-reference handles, enum names and plain text are untouched, and a string that fails to parse still produces the original validation error.

### Fixed
- An Editor whose IPC listener died is no longer advertised as reachable. The listener could stop accepting connections for good — an unexpected socket failure, or a first bind that never succeeded — while the instance kept publishing itself, so the CLI would route to it and fail to connect over and over. The bridge now watches its own listener and re-binds it in place within a few seconds; if it cannot be revived, the instance removes itself from the registry with an Editor console message telling you to restart, instead of staying on the list as a target that never answers.
- The IPC auth token is now compared in fixed time, so the check cannot leak the expected token through timing to other processes on the same machine. Authentication behavior is otherwise unchanged.

## [0.5.2] - 2026-08-13

### Added
Expand Down
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,9 @@ The repo is a single solution (`UnityCliBridge.sln`) split across four projects:
**Bridge runtime (`unity-package/com.yhc509.unity-cli-bridge/Editor/`)** — Hosted in the Unity Editor.
- `BridgeHost.cs` is the bootstrap and dispatcher: it registers the project in the instance registry, starts the IPC listener (Named Pipe on Windows / Unix socket on macOS+Linux), and routes commands to one of the `*CommandHandler` classes (`Asset`, `AssetCreate`, `Scene`, `Prefab`, `Material`, `Package`, `Qa`, `Record`, `Profile`, `Screenshot`, `ExecuteCode`, `Custom`). It runs in any main editor process including `-batchmode`; secondary processes (MPE / `-adb2` AssetImportWorker) are excluded.
- `ClientDisconnectMonitor.cs` is the `.NET`-testable stream watcher that lets `BridgeHost` cancel queued requests when the CLI connection closes before dispatch.
- `EditorTickPump.cs` wraps `EditorApplication.update` subscription for deferred flows and forces `EditorApplication.SignalTick` (reflection) while any of them is subscribed — see the auto-tick convention below.
- Scene/prefab patch logic is deliberately split across `SceneCommandHandler.Patching.cs` and `PrefabCommandHandler.Patching.cs` (partial classes) so the entry-point file stays small and the op-application code lives next to its inspector.
- `SerializedValueApplier.cs` (+ `.ComplexTypes.cs` partial) is the most fragile layer — it translates JSON values into `SerializedProperty.propertyPath` mutations with friendly-key fallback. Run `*-inspect --with-values` before patching to verify paths.
- `SerializedValueApplier.cs` (+ `.ComplexTypes.cs` partial) is the most fragile layer — it translates JSON values into `SerializedProperty.propertyPath` mutations with friendly-key fallback. Run `*-inspect --with-values` before patching to verify paths. `ApplyToken` is the single funnel for every value (top-level, nested, array element), so agent-input coercion belongs there and nowhere else.
- `ExecuteValueSerializer.cs` serializes values returned from `execute` and custom commands with a safe whitelist and round-trip float/double formatting.
- `AssetBackupTransaction.cs` wraps `FileBackupTransaction` with an `AssetDatabase.Refresh` discipline (always in `finally`) and is the rollback core for scene patch / prefab patch / asset overwrite. Backups land in `Library/com.yhc509.unity-cli-bridge/backups/` to stay outside `Assets/` and `AssetDatabase` scanning.
- `PackageCommandHandler.cs` polls Unity Package Manager from `EditorApplication.update` (deferred dispatch) with a single-flight guard and a 300 s `PACKAGE_TIMEOUT` — never re-introduce blocking polls here.
Expand All @@ -70,6 +71,10 @@ Tests live in `tests/UnityCli.Cli.Tests/` (xUnit, `.NET`-testable surface only).
- **macOS paths:** Use real paths (`pwd -P`), not symlinks, for hashing and registry lookups.
- **Instance primary identity:** Registry and CLI routing use canonical `projectRoot` first. The 12-character hash is only for socket/pipe names and user-input fallback; if a hash matches multiple instances, require a project path.
- **No-`--project` routing:** With no `--project`, routing resolves CWD → pinned `activeProjectRoot` (set via `instances use`) → the single live instance. If two or more live instances remain and none is pinned, the command fails with a `CLI_USAGE` error listing candidates instead of silently picking one. Auto-promoted `activeProjectRoot` (most-recent live Editor) is not trusted for this fallback; only an explicit `instances use` pin is.
- **Auto-tick pump:** deferred flows (test/package/profile/record/qa run-sequence) must subscribe their `EditorApplication.update` poll through `EditorTickPump.Add/Remove`, never directly — a direct subscription silently opts that flow out of the pump. While at least one is subscribed the pump forces `EditorApplication.SignalTick` (bound once by reflection, throttled to `DefaultIntervalMs` = 16) and disarms when the set empties, so the tick state reverts on its own. The subscriber set holds the very delegates on `EditorApplication.update`, so a pump leak would be a poll leak; `Remove` is idempotent to match the defensive double-unsubscribes in the poll bodies. Measured effect on Unity 6000.3 / macOS: ~160 ms → ~100 ms per tick (≈1.55×), **the same focused or unfocused** — on that version focus itself barely changes the edit-mode update rate, so the pump is a general speed-up rather than an unfocused-only fix. `SignalTick` is internal API: a bind failure degrades to a no-op with one warning, never a throw. `BridgeHost.OnEditorUpdate` stays a direct subscription — routing it through the pump would pin the editor at full rate for the whole session.
- **Listener watchdog:** the accept loops have no retry of their own (the Unix loop ends permanently on any unexpected `AcceptAsync` failure; both loops give up if the first acquire fails), so `BridgeHost` runs `ListenerWatchdogPolicy` (`Runtime/Protocol/`, Unity-free and unit-tested) on every editor tick. Both loops must clear `_isListenerReady` in their `finally`, and every bind outcome must clear `_isListenerStarting` — those two flags are the entire input. The watchdog skips while a bind is in flight or the editor is compiling/updating (domain-reload teardown is not a fault), rebinds in place up to 5 times at 5 s intervals, re-publishes registry + token sidecar on recovery (deleting the old sidecar if the rebind landed on a different hash), and on exhaustion unregisters the instance so it never advertises as live while unreachable.
- **Agent value coercion:** a value that is a string, targets a property that cannot hold a raw string, and starts with `{` or `[` after trimming is re-parsed as JSON at the `ApplyToken` entry. Keep the guard exactly that narrow — asset paths, object-reference handles, enum names and char values never open with those characters, so they keep the original path, and a re-parse failure falls back to the original token so the existing validation error is preserved. Vector-family readers additionally accept a flat numeric array (`[1,2,3]`) next to the canonical member object; `Bounds`/`BoundsInt` deliberately do not (their nested `center`/`size` accept arrays instead).
- **Auth token comparison:** `AuthTokenComparison.FixedTimeEquals` (no early exit, length folded into the same accumulator) is the only comparison allowed on the token path. Hand-rolled rather than `CryptographicOperations.FixedTimeEquals` so the shared source compiles on Unity's runtime profile and .NET 9.
- **Auth token storage:** Live IPC auth tokens are stored in per-instance 0600 sidecars (`<registryDir>/tokens/<hash>.token`), not in the shared registry. The CLI reads the sidecar during resolve/load, and `InstanceRecord.token` is a non-serialized in-memory field. The token sidecar is written as soon as the listener key is acquired, before a client can connect; the registry entry is only written on the first editor tick after the listener is ready. A live socket with no registry entry is therefore a real (if brief) state, and the CLI must treat it as a retryable `LIVE_UNAVAILABLE` rather than connecting with an empty token — do not remove that guard. Both the entry and the sidecar survive domain reloads and are removed only when the Editor exits; during a reload only the socket/listener is closed.
- **Token sidecar is overwrite-if-different:** `EnsureTokenSidecar` replaces a stale sidecar left by a killed/crashed session; never restore the old "skip when file exists" behavior — it makes every CLI call fail `UNAUTHORIZED` for the whole next session.
- **Headless editors are first-class:** the bridge starts in any main editor process (GUI or `-batchmode`); only secondary Unity processes (MPE / `-adb2` AssetImportWorker) are excluded. Recommended headless launch is `-batchmode` *without* `-nographics` so the GPU stays available. Commands whose catalog entry sets `requiresGraphics` fail with `HEADLESS_NO_GRAPHICS` when `SystemInfo.graphicsDeviceType == Null`.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,7 @@ docs/ Generated CLI reference, specs
- **Scene paths:** `/Root[0]/Child[0]` format with sibling indices. `/` is the virtual scene root.
- **Inspect before patch:** Always `scene inspect --with-values` or `prefab inspect --with-values` before writing patch specs.
- **Friendly component keys:** Common Rigidbody, Collider, Renderer, Light, and Camera patch keys are resolved to Unity `SerializedProperty.propertyPath` values.
- **Component value shapes:** Vector2/3/4, Vector2Int/Vector3Int, Quaternion, Rect, RectInt, and Color accept both the member object (`{"x":1,"y":2,"z":3}`) and the array shorthand (`[1,2,3]`); a Color array may omit alpha. A structured value that arrives quoted (`"[1,2,3]"`) is re-parsed, but send real JSON values — the fallback never applies to genuine string fields such as asset paths or enum names.
- **set-node warnings:** Unrecognized keys now return warnings instead of silent success.

## Documentation
Expand Down
58 changes: 58 additions & 0 deletions tests/UnityCli.Cli.Tests/AuthTokenComparisonTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using UnityCli.Protocol;

namespace UnityCli.Cli.Tests;

public sealed class AuthTokenComparisonTests
{
[Fact]
public void FixedTimeEquals_WhenIdentical_ReturnsTrue()
{
string token = new string('a', 64);

Assert.True(AuthTokenComparison.FixedTimeEquals(token, new string('a', 64)));
}

[Fact]
public void FixedTimeEquals_WhenLastCharacterDiffers_ReturnsFalse()
{
string expected = new string('a', 64);
string candidate = expected.Substring(0, 63) + "b";

Assert.False(AuthTokenComparison.FixedTimeEquals(expected, candidate));
}

[Fact]
public void FixedTimeEquals_WhenFirstCharacterDiffers_ReturnsFalse()
{
string expected = new string('a', 64);
string candidate = "b" + expected.Substring(1);

Assert.False(AuthTokenComparison.FixedTimeEquals(expected, candidate));
}

[Fact]
public void FixedTimeEquals_IsCaseSensitive()
{
Assert.False(AuthTokenComparison.FixedTimeEquals("abcdef", "ABCDEF"));
}

[Theory]
[InlineData("token", "")]
[InlineData("", "token")]
[InlineData("", "")]
[InlineData("token", null)]
[InlineData(null, "token")]
[InlineData(null, null)]
public void FixedTimeEquals_WhenEitherSideIsMissing_ReturnsFalse(string? expected, string? candidate)
{
Assert.False(AuthTokenComparison.FixedTimeEquals(expected!, candidate!));
}

[Theory]
[InlineData("abcdef", "abcde")]
[InlineData("abcde", "abcdef")]
public void FixedTimeEquals_WhenPrefixMatchesButLengthDiffers_ReturnsFalse(string expected, string candidate)
{
Assert.False(AuthTokenComparison.FixedTimeEquals(expected, candidate));
}
}
111 changes: 111 additions & 0 deletions tests/UnityCli.Cli.Tests/EditorTickPumpPolicyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
using UnityCli.DocGen;

namespace UnityCli.Cli.Tests;

/// <summary>
/// The tick pump only speeds up work that is subscribed through it. These are source-level guards
/// for the wiring — the pump itself needs a live editor, so it cannot be exercised here.
/// </summary>
public sealed class EditorTickPumpPolicyTests
{
public static TheoryData<string> DeferredHandlerFiles()
{
var files = new TheoryData<string>();
files.Add("TestCommandHandler.cs");
files.Add("TestCommandHandler.PlayMode.cs");
files.Add("PackageCommandHandler.cs");
files.Add("ProfileCommandHandler.cs");
files.Add("ProfileCommandHandler.Capture.cs");
files.Add("ProfileCommandHandler.Memory.cs");
files.Add("QaCommandHandler.cs");
files.Add("QaCommandHandler.Sequence.cs");
files.Add("RecordCommandHandler.cs");
return files;
}

private static string ReadEditorSource(string fileName)
{
string repoRoot = RepositoryPaths.FindRepoRoot(AppContext.BaseDirectory);
return File.ReadAllText(Path.Combine(
repoRoot,
"unity-package",
"com.yhc509.unity-cli-bridge",
"Editor",
fileName));
}

[Theory]
[MemberData(nameof(DeferredHandlerFiles))]
public void DeferredHandlers_SubscribeThroughTheTickPump(string fileName)
{
string source = ReadEditorSource(fileName);

// A direct subscription still works, it just silently opts that flow out of the pump and
// leaves it running at the unfocused editor's throttled tick rate.
Assert.DoesNotContain("EditorApplication.update +=", source);
Assert.DoesNotContain("EditorApplication.update -=", source);
Assert.Contains("EditorTickPump.", source);
}

[Theory]
[MemberData(nameof(DeferredHandlerFiles))]
public void DeferredHandlers_ReleaseThePumpOnEveryPathThatSubscribes(string fileName)
{
string source = ReadEditorSource(fileName);
int adds = CountOccurrences(source, "EditorTickPump.Add(");
int removes = CountOccurrences(source, "EditorTickPump.Remove(");

// Removes are idempotent and the poll bodies exit on several paths, so the useful
// invariant is that nothing subscribes without a matching teardown somewhere.
Assert.True(adds > 0, fileName + " should drive at least one deferred poll.");
Assert.True(
removes >= adds,
fileName + " subscribes " + adds + " poll(s) but only unsubscribes " + removes + " time(s).");
}

[Fact]
public void TickPump_BindsSignalTickReflectivelyAndDegradesWhenMissing()
{
string source = ReadEditorSource("EditorTickPump.cs");

Assert.Contains("\"SignalTick\"", source);
Assert.Contains("BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public", source);
Assert.Contains("Delegate.CreateDelegate(typeof(Action), method)", source);

// An internal API that disappears must cost speed, not availability.
Assert.DoesNotContain("throw new", source);
}

[Fact]
public void TickPump_RunsOnlyWhileDeferredWorkIsSubscribed()
{
string source = ReadEditorSource("EditorTickPump.cs");

Assert.Contains("_subscribers.Count > 0", source);
Assert.Contains("EditorApplication.update -= Pump;", source);
}

[Fact]
public void BridgeHost_KeepsItsOwnUpdateHookOutOfThePump()
{
string source = ReadEditorSource("BridgeHost.cs");

// The host update hook is permanent; routing it through the pump would pin the editor at
// full tick rate for the entire editor session.
Assert.Contains("EditorApplication.update += OnEditorUpdate;", source);
Assert.DoesNotContain("EditorTickPump.Add(OnEditorUpdate)", source);
}

private static int CountOccurrences(string source, string value)
{
int count = 0;
int index = source.IndexOf(value, StringComparison.Ordinal);
while (index >= 0)
{
count++;
index = source.IndexOf(value, index + value.Length, StringComparison.Ordinal);
}

return count;
}
}
Loading