147 lines
6.8 KiB
Swift
147 lines
6.8 KiB
Swift
import Foundation
|
|
import NucleicCore
|
|
|
|
/// Tier-2 contract-test stub (OBSERVABILITY B.4), the Grok analog of `fake-claude`: speaks the
|
|
/// real Grok Build wire protocols off a recorded fixture — `streaming-json` on stdout, and for
|
|
/// approval directives it discovers the per-session approval bridge exactly as Grok's generated
|
|
/// PreToolUse hook would (from the `hooks.json` the adapter wrote) and performs the actual HTTP
|
|
/// `POST /grok-hook`, asserting on the reply. Runs through the production ProcessHost + adapter
|
|
/// with no `grok` installed and no cost.
|
|
///
|
|
/// Fixture lines are either verbatim native streaming-json (echoed to stdout) or directives:
|
|
/// {"__fake__":"approval","tool_name":…,"input":…,"tool_call_id":…,
|
|
/// "expect":"allow"|"deny"} — full hook round-trip, assert behavior
|
|
/// {"__fake__":"sleep","ms":50} — pacing
|
|
///
|
|
/// Exit codes: 0 ok · 4 approval-reply mismatch · 5 setup/handshake failure.
|
|
@main
|
|
struct FakeGrok {
|
|
static func main() async {
|
|
guard let fixturePath = ProcessInfo.processInfo.environment["FAKE_GROK_FIXTURE"] else {
|
|
die(5, "FAKE_GROK_FIXTURE not set")
|
|
}
|
|
guard let fixture = try? String(contentsOfFile: fixturePath, encoding: .utf8) else {
|
|
die(5, "cannot read fixture \(fixturePath)")
|
|
}
|
|
|
|
for rawLine in fixture.split(separator: "\n", omittingEmptySubsequences: true) {
|
|
let line = String(rawLine)
|
|
guard let parsed = try? JSONValue(parsing: line),
|
|
let directive = parsed["__fake__"]?.stringValue
|
|
else {
|
|
emit(line)
|
|
continue
|
|
}
|
|
|
|
switch directive {
|
|
case "approval":
|
|
await performApproval(parsed)
|
|
case "sleep":
|
|
let ms = parsed["ms"]?.intValue ?? 10
|
|
try? await Task.sleep(for: .milliseconds(ms))
|
|
default:
|
|
die(5, "unknown directive \(directive)")
|
|
}
|
|
}
|
|
exit(0)
|
|
}
|
|
|
|
static func emit(_ line: String) {
|
|
FileHandle.standardOutput.write(Data((line + "\n").utf8))
|
|
}
|
|
|
|
static func die(_ code: Int32, _ message: String) -> Never {
|
|
FileHandle.standardError.write(Data(("fake-grok: " + message + "\n").utf8))
|
|
exit(code)
|
|
}
|
|
|
|
// MARK: - PreToolUse hook round-trip (GROK_ADAPTER §3, Tier A)
|
|
|
|
/// Discover the approval bridge the way Grok's real hook would — from the project-local
|
|
/// `.grok/hooks.json` the adapter generated in the worktree (the diagnostic
|
|
/// `NUCLEIC_GROK_HOOKS_CONFIG` env points straight at it) → the hook script → its
|
|
/// `SERVER_URL` / `AUTH` literals — then POST the gated call to `/grok-hook` and assert the
|
|
/// reply behavior.
|
|
static func performApproval(_ directive: JSONValue) async {
|
|
guard let (url, authorization) = discoverHookBridge() else {
|
|
die(5, "could not discover the grok hook bridge from the generated config")
|
|
}
|
|
|
|
var body: [String: JSONValue] = [
|
|
"tool_name": directive["tool_name"] ?? .string("unknown"),
|
|
"input": directive["input"] ?? .object([:]),
|
|
]
|
|
if let toolCallID = directive["tool_call_id"] {
|
|
body["tool_call_id"] = toolCallID
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.setValue(authorization, forHTTPHeaderField: "Authorization")
|
|
request.httpBody = try? JSONValue.object(body).encodedData()
|
|
request.timeoutInterval = 60
|
|
guard let (data, response) = try? await URLSession.shared.data(for: request),
|
|
let http = response as? HTTPURLResponse, http.statusCode == 200,
|
|
let reply = try? JSONValue(parsing: data),
|
|
let behavior = reply["behavior"]?.stringValue
|
|
else {
|
|
die(4, "POST /grok-hook did not return a usable {behavior,…} reply")
|
|
}
|
|
|
|
// The confirmed contract: allow must echo updatedInput; deny must carry a message.
|
|
if behavior == "allow", reply["updatedInput"] == nil {
|
|
die(4, "allow reply missing updatedInput")
|
|
}
|
|
if behavior == "deny", reply["message"]?.stringValue == nil {
|
|
die(4, "deny reply missing message")
|
|
}
|
|
if let expected = directive["expect"]?.stringValue, behavior != expected {
|
|
die(4, "expected behavior \(expected), got \(behavior)")
|
|
}
|
|
}
|
|
|
|
/// Read the generated `.grok/hooks.json` → hook script → `(server URL, Authorization header)`.
|
|
/// The configured `command` is the `pre_tool.sh` wrapper; the `SERVER_URL` / `AUTH` literals
|
|
/// live in the sibling `pre_tool.py`, so read that (falling back to the command file itself).
|
|
private static func discoverHookBridge() -> (URL, String)? {
|
|
guard let configPath = hooksConfigPath(),
|
|
let configData = try? Data(contentsOf: URL(fileURLWithPath: configPath)),
|
|
let config = try? JSONValue(parsing: configData),
|
|
let commandPath = config["hooks"]?["PreToolUse"]?[0]?["hooks"]?[0]?["command"]?.stringValue
|
|
else { return nil }
|
|
|
|
let pyPath = URL(fileURLWithPath: commandPath)
|
|
.deletingLastPathComponent().appendingPathComponent("pre_tool.py").path
|
|
let script = (try? String(contentsOfFile: pyPath, encoding: .utf8))
|
|
?? (try? String(contentsOfFile: commandPath, encoding: .utf8))
|
|
guard let script,
|
|
let serverURL = literal(named: "SERVER_URL", in: script),
|
|
let auth = literal(named: "AUTH", in: script),
|
|
let url = URL(string: serverURL)
|
|
else { return nil }
|
|
return (url, auth)
|
|
}
|
|
|
|
/// `hooks.json` path: the diagnostic `NUCLEIC_GROK_HOOKS_CONFIG` env the adapter sets, else
|
|
/// the project-local `.grok/hooks.json` in the cwd (the worktree) — exactly where the real
|
|
/// Grok PreToolUse hook is discovered.
|
|
private static func hooksConfigPath() -> String? {
|
|
if let env = ProcessInfo.processInfo.environment["NUCLEIC_GROK_HOOKS_CONFIG"] { return env }
|
|
let cwd = FileManager.default.currentDirectoryPath
|
|
let local = cwd + "/.grok/hooks.json"
|
|
return FileManager.default.fileExists(atPath: local) ? local : nil
|
|
}
|
|
|
|
/// Parse a `NAME = "<json string>"` line out of the generated Python hook script.
|
|
private static func literal(named name: String, in script: String) -> String? {
|
|
for line in script.split(separator: "\n") {
|
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
guard trimmed.hasPrefix("\(name) = ") else { continue }
|
|
let rhs = String(trimmed.dropFirst("\(name) = ".count))
|
|
return (try? JSONValue(parsing: Data(rhs.utf8)))?.stringValue
|
|
}
|
|
return nil
|
|
}
|
|
}
|