Fix Claude hook transcript scaling and Sparkle update packaging (#5202)

* test: cover Claude hook transcript scaling and Sparkle XPC cleanup

* fix: bound Claude hook transcript reads and strip Sparkle XPC services

* fix: handle oversized Claude transcript tail lines

* test: cover oversized Claude transcript tail line

* fix: strip Sparkle XPC services during shared signing
This commit is contained in:
Lawrence Chen
2026-06-02 05:14:12 -07:00
committed by GitHub
parent 7d4b8e4fd4
commit 3601aa0076
6 changed files with 200 additions and 14 deletions
+51 -14
View File
@@ -22206,13 +22206,7 @@ struct CMUXCLI {
}
private func readTranscriptSummary(path: String) -> TranscriptSummary? {
let expandedPath = NSString(string: path).expandingTildeInPath
guard let data = try? Data(contentsOf: URL(fileURLWithPath: expandedPath)) else {
return nil
}
guard let content = String(data: data, encoding: .utf8) else { return nil }
let lines = content.components(separatedBy: "\n")
guard let lines = readRecentTranscriptLines(path: path) else { return nil }
var lastAssistantMessage: String?
@@ -22236,6 +22230,54 @@ struct CMUXCLI {
return TranscriptSummary(lastAssistantMessage: lastAssistantMessage)
}
private func readRecentTranscriptLines(
path: String,
maxBytes: UInt64 = 1_048_576
) -> [String]? {
let expandedPath = NSString(string: path).expandingTildeInPath
guard let handle = try? FileHandle(forReadingFrom: URL(fileURLWithPath: expandedPath)) else {
return nil
}
defer { try? handle.close() }
let size: UInt64
do {
size = try handle.seekToEnd()
let start = size > maxBytes ? size - maxBytes : 0
try handle.seek(toOffset: start)
guard let data = try handle.readToEnd(), !data.isEmpty else {
return nil
}
var text = String(decoding: data, as: UTF8.self)
if start > 0 {
var readStart = start
let maxWindowBytes = maxBytes > UInt64.max / 8 ? UInt64.max : maxBytes * 8
while text.firstIndex(of: "\n") == nil, readStart > 0 {
let currentWindowBytes = size - readStart
guard currentWindowBytes < maxWindowBytes else { break }
let remainingWindowBytes = maxWindowBytes - currentWindowBytes
let expansionBytes = min(readStart, maxBytes, remainingWindowBytes)
guard expansionBytes > 0 else { break }
readStart -= expansionBytes
try handle.seek(toOffset: readStart)
guard let expandedData = try handle.readToEnd(), !expandedData.isEmpty else {
return nil
}
text = String(decoding: expandedData, as: UTF8.self)
}
if readStart > 0, let newline = text.firstIndex(of: "\n") {
text.removeSubrange(...newline)
}
}
return text.components(separatedBy: "\n")
} catch {
return nil
}
}
private struct CodexHookFailureSummary {
let statusValue: String
let subtitle: String
@@ -28471,19 +28513,14 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) {
path: String,
matchingToolName: String?
) -> [String: Any]? {
let expandedPath = NSString(string: path).expandingTildeInPath
guard let data = try? Data(contentsOf: URL(fileURLWithPath: expandedPath)),
let content = String(data: data, encoding: .utf8)
else {
return nil
}
guard let lines = readRecentTranscriptLines(path: path) else { return nil }
var lastUserMessage: String?
var lastAssistantText: String?
var permissionMode: String?
var matchedContext: [String: Any]?
for line in content.components(separatedBy: "\n") {
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty,
let lineData = trimmed.data(using: .utf8),
@@ -73,6 +73,77 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase {
)
}
func testClaudePreToolUseFeedContextReadsOnlyRecentTranscriptTail() throws {
let context = try makeClaudeHookContext(name: "claude-pretool-tail")
defer { context.cleanup() }
let transcriptURL = context.root.appendingPathComponent("large-claude-session.jsonl")
_ = FileManager.default.createFile(atPath: transcriptURL.path, contents: nil)
let handle = try FileHandle(forWritingTo: transcriptURL)
func writeLine(_ line: String) throws {
try handle.write(contentsOf: Data((line + "\n").utf8))
}
try writeLine(#"{"type":"user","message":{"role":"user","content":"ancient user message"}}"#)
try writeLine(#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"ancient assistant response"},{"type":"tool_use","name":"Bash","input":{"command":"echo old"}}]}}"#)
let fillerPayload = String(repeating: "x", count: 1_200)
for _ in 0..<1_200 {
try writeLine(#"{"type":"user","message":{"role":"user","content":"\#(fillerPayload)"}}"#)
}
try writeLine(#"{"type":"user","message":{"role":"user","content":"recent user message"}}"#)
try writeLine(#"{"type":"assistant","message":{"role":"assistant","content":"recent assistant response"}}"#)
try handle.close()
let result = runClaudeHook(
context: context,
arguments: ["hooks", "claude", "pre-tool-use"],
standardInput: #"{"session_id":"tail-session","turn_id":"turn-1","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo recent"}}"#
)
XCTAssertFalse(result.timedOut, result.stderr)
XCTAssertEqual(result.status, 0, result.stderr)
let preToolEvent = try XCTUnwrap(
feedPushEvents(in: context).last { $0["hook_event_name"] as? String == "PreToolUse" }
)
let feedContext = try XCTUnwrap(preToolEvent["context"] as? [String: Any])
XCTAssertEqual(feedContext["lastUserMessage"] as? String, "recent user message")
XCTAssertEqual(feedContext["assistantPreamble"] as? String, "recent assistant response")
XCTAssertFalse(String(describing: feedContext).contains("ancient"), "\(feedContext)")
}
func testClaudePreToolUseFeedContextKeepsOversizedFinalTranscriptLine() throws {
let context = try makeClaudeHookContext(name: "claude-pretool-oversized-final")
defer { context.cleanup() }
let transcriptURL = context.root.appendingPathComponent("oversized-final-claude-session.jsonl")
_ = FileManager.default.createFile(atPath: transcriptURL.path, contents: nil)
let handle = try FileHandle(forWritingTo: transcriptURL)
defer { try? handle.close() }
try handle.write(contentsOf: Data(#"{"type":"user","message":{"role":"user","content":"ancient user message"}}"#.utf8))
try handle.write(contentsOf: Data("\n".utf8))
let longAssistantText = "recent assistant response " + String(repeating: "r", count: 1_100_000)
let finalLine = #"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"\#(longAssistantText)"},{"type":"tool_use","name":"Bash","input":{"command":"echo huge"}}]}}"#
try handle.write(contentsOf: Data(finalLine.utf8))
try handle.close()
let result = runClaudeHook(
context: context,
arguments: ["hooks", "claude", "pre-tool-use"],
standardInput: #"{"session_id":"oversized-final-session","turn_id":"turn-1","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo huge"}}"#
)
XCTAssertFalse(result.timedOut, result.stderr)
XCTAssertEqual(result.status, 0, result.stderr)
let preToolEvent = try XCTUnwrap(
feedPushEvents(in: context).last { $0["hook_event_name"] as? String == "PreToolUse" }
)
let feedContext = try XCTUnwrap(preToolEvent["context"] as? [String: Any])
let assistantPreamble = try XCTUnwrap(feedContext["assistantPreamble"] as? String)
XCTAssertTrue(assistantPreamble.hasPrefix("recent assistant response"), "\(feedContext)")
}
func testCodexPromptSubmitRefreshesLastTurnDiffBaseline() throws {
let context = try makeClaudeHookContext(name: "codex-prompt-baseline")
defer { context.cleanup() }
@@ -7608,6 +7679,18 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase {
return try XCTUnwrap(sessions[sessionId] as? [String: Any])
}
private func feedPushEvents(in context: ClaudeHookContext) -> [[String: Any]] {
context.state.snapshot().compactMap { line in
guard let payload = jsonObject(line),
payload["method"] as? String == "feed.push",
let params = payload["params"] as? [String: Any],
let event = params["event"] as? [String: Any] else {
return nil
}
return event
}
}
func testBrowserImportDefaultsNonInteractiveInCodingAgent() throws {
let cliPath = try bundledCLIPath()
let socketPath = makeSocketPath("browser-import-agent")
+4
View File
@@ -90,6 +90,10 @@ APP_PLIST="$APP_PATH/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Add :SUFeedURL string https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml" "$APP_PLIST"
echo "Sparkle keys injected"
# cmux is a non-sandboxed app. Sparkle's sandbox-only XPC services make the
# installer handoff wait for an agent connection that never arrives.
./scripts/remove-sparkle-sandbox-xpc-services.sh "$APP_PATH"
# --- Codesign ---
echo "Codesigning..."
CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "Usage: scripts/remove-sparkle-sandbox-xpc-services.sh <app-path>" >&2
exit 2
fi
APP_PATH="$1"
SPARKLE_FRAMEWORK="$APP_PATH/Contents/Frameworks/Sparkle.framework"
if [[ ! -d "$SPARKLE_FRAMEWORK" ]]; then
exit 0
fi
find "$SPARKLE_FRAMEWORK" \
-path '*/XPCServices' \
\( -type d -o -type l \) \
-prune \
-print |
while IFS= read -r xpc_dir; do
rm -rf "$xpc_dir"
echo "Removed Sparkle sandbox XPC services: $xpc_dir"
done
+1
View File
@@ -76,6 +76,7 @@ fi
# 3. Frameworks
if [[ -d "$APP_PATH/Contents/Frameworks" ]]; then
"$SCRIPT_DIR/remove-sparkle-sandbox-xpc-services.sh" "$APP_PATH"
while IFS= read -r -d '' framework; do
echo "==> signing framework $(basename "$framework")"
/usr/bin/codesign "${COMMON[@]}" --deep "$framework"
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cmux-sparkle-xpc-test.XXXXXX")"
trap 'rm -rf "$WORK_DIR"' EXIT
APP_PATH="$WORK_DIR/cmux.app"
SPARKLE_DIR="$APP_PATH/Contents/Frameworks/Sparkle.framework"
VERSION_DIR="$SPARKLE_DIR/Versions/B"
XPC_DIR="$VERSION_DIR/XPCServices"
mkdir -p "$XPC_DIR/Installer.xpc" "$XPC_DIR/Downloader.xpc" "$VERSION_DIR/Resources"
touch "$XPC_DIR/Installer.xpc/Info.plist"
touch "$XPC_DIR/Downloader.xpc/Info.plist"
touch "$VERSION_DIR/Resources/keep.txt"
ln -s B "$SPARKLE_DIR/Versions/Current"
ln -s Versions/Current/XPCServices "$SPARKLE_DIR/XPCServices"
"$ROOT_DIR/scripts/remove-sparkle-sandbox-xpc-services.sh" "$APP_PATH" >/dev/null
if [[ -e "$XPC_DIR" ]]; then
echo "FAIL: Sparkle XPCServices directory was not removed" >&2
exit 1
fi
if [[ -e "$SPARKLE_DIR/XPCServices" || -L "$SPARKLE_DIR/XPCServices" ]]; then
echo "FAIL: Sparkle XPCServices symlink was not removed" >&2
exit 1
fi
if [[ ! -e "$VERSION_DIR/Resources/keep.txt" ]]; then
echo "FAIL: Sparkle cleanup removed unrelated framework contents" >&2
exit 1
fi
echo "PASS: Sparkle sandbox XPC services are stripped from non-sandboxed release bundles"