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:
+51
-14
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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"
|
||||
|
||||
Executable
+37
@@ -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"
|
||||
Reference in New Issue
Block a user