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
16 changes: 8 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ members = [
resolver = "3"

[workspace.package]
version = "0.10.2"
version = "0.10.3"
edition = "2024"
license = "MIT OR Apache-2.0"
authors = ["TorroMail contributors"]
Expand Down
136 changes: 112 additions & 24 deletions apps/TorroMailApp/Sources/TorroMailKit/TorroMailKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1141,9 +1141,9 @@ public enum MCPClientRegistry {
))
}

// OpenCode keeps its config under ~/.config on every platform. A
// `.jsonc` is used when it is the only one there; comments in it make
// it unreadable to a strict parser — an error, never an overwrite.
// OpenCode keeps its config under ~/.config on every platform. Use its
// `.jsonc` when it is the only one there; the setup editor preserves
// comments and trailing commas while changing only TorroMail's entry.
let openCodeDirectory = home.appendingPathComponent(".config/opencode", isDirectory: true)
if fileManager.fileExists(atPath: openCodeDirectory.path) {
let json = openCodeDirectory.appendingPathComponent("opencode.json")
Expand Down Expand Up @@ -1412,12 +1412,11 @@ public enum MCPClientSetup {
}
}

/// A deliberately small JSON5 object editor for OpenClaw's owned config.
/// A deliberately small JSON5/JSONC object editor for client-owned config.
/// It understands comments, quoted or bare keys, trailing commas, strings,
/// arrays and nested objects. TorroMail only changes one path
/// (`mcp.servers.torromail`), leaving every byte outside that property
/// alone. This is the fallback when the separately installed OpenClaw app
/// has no CLI launcher visible to a sandboxed GUI process.
/// arrays and nested objects. TorroMail changes only its own MCP property,
/// leaving every byte outside that property alone. OpenClaw needs the JSON5
/// support; OpenCode needs the JSONC subset.
private struct JSON5Document {
private struct Property {
var name: String
Expand Down Expand Up @@ -1450,6 +1449,22 @@ public enum MCPClientSetup {
return false
}

func string(path: [String]) throws -> String? {
guard !path.isEmpty, let root = try rootObject() else { return nil }
var object = root
for (index, component) in path.enumerated() {
guard let property = try properties(in: object).first(where: { $0.name == component }) else {
return nil
}
if index == path.count - 1 {
return try stringValue(at: property.valueStart, limit: property.valueEnd)
}
guard let nested = try self.object(at: property.valueStart) else { return nil }
object = nested
}
return nil
}

mutating func set(path: [String], jsonValue: String) throws {
guard !path.isEmpty, let root = try rootObject() else {
throw Failure("The existing configuration could not be read.")
Expand Down Expand Up @@ -1636,6 +1651,18 @@ public enum MCPClientSetup {
return (String(decoding: bytes[start..<index], as: UTF8.self), index)
}

private func stringValue(at start: Int, limit: Int) throws -> String? {
guard start < limit, bytes[start] == 0x22,
let end = try skippedStringOrComment(from: start, limit: limit),
skipTrivia(from: end, limit: limit) == limit else {
return nil
}
return (try? JSONSerialization.jsonObject(
with: Data(bytes[start..<end]),
options: [.fragmentsAllowed]
)) as? String
}

private func valueSeparator(from start: Int, objectEnd: Int) throws -> Int {
var braces = 0
var brackets = 0
Expand Down Expand Up @@ -1788,9 +1815,10 @@ public enum MCPClientSetup {
/// Whether a client already points at TorroMail.
public static func isConfigured(_ client: MCPClient) -> Bool {
switch client.setup {
case let .mcpServersJSON(configURL), let .serversJSON(configURL), let .openCodeJSON(configURL),
let .claudeCodeCLI(_, configURL):
case let .mcpServersJSON(configURL), let .serversJSON(configURL), let .claudeCodeCLI(_, configURL):
return jsonConfigHasServer(at: configURL, rootKey: client.setup.jsonRootKey)
case let .openCodeJSON(configURL):
return openCodeConfigHasServer(at: configURL)
case let .codexCLI(_, configURL):
// Reading the file beats launching the CLI on every refresh, and
// a TOML table header is unambiguous enough to scan for.
Expand Down Expand Up @@ -1819,6 +1847,13 @@ public enum MCPClientSetup {
}

private static let openClawServerPath = ["mcp", "servers", MCPClientRegistry.serverName]
private static let openCodeServerPath = ["mcp", MCPClientRegistry.serverName]
private static let openCodeTokenPath = openCodeServerPath + ["environment", "TORROMAIL_TOKEN"]

private static func openCodeConfigHasServer(at configURL: URL) -> Bool {
guard let data = try? Data(contentsOf: configURL), !data.isEmpty else { return false }
return (try? JSON5Document(data: data).contains(path: openCodeServerPath)) == true
}

private static func openClawConfigHasServer(at configURL: URL) -> Bool {
guard let data = try? Data(contentsOf: configURL), !data.isEmpty else { return false }
Expand Down Expand Up @@ -1846,13 +1881,15 @@ public enum MCPClientSetup {
return false
}
switch client.setup {
case let .mcpServersJSON(configURL), let .serversJSON(configURL), let .openCodeJSON(configURL),
let .claudeCodeCLI(_, configURL):
case let .mcpServersJSON(configURL), let .serversJSON(configURL), let .claudeCodeCLI(_, configURL):
return jsonConfigToken(
at: configURL,
rootKey: client.setup.jsonRootKey,
environmentKey: client.setup.jsonEnvironmentKey
) == token
case let .openCodeJSON(configURL):
guard let data = try? Data(contentsOf: configURL), !data.isEmpty else { return false }
return (try? JSON5Document(data: data).string(path: openCodeTokenPath)) == token
case let .codexCLI(_, configURL):
// The key is high-entropy, so plain containment on the TOML is
// unambiguous — better than parsing a format we never write.
Expand Down Expand Up @@ -2009,14 +2046,21 @@ public enum MCPClientSetup {
fileManager: FileManager = .default
) throws {
switch client.setup {
case let .mcpServersJSON(configURL), let .serversJSON(configURL), let .openCodeJSON(configURL):
case let .mcpServersJSON(configURL), let .serversJSON(configURL):
try addToJSONConfig(
at: configURL,
rootKey: client.setup.jsonRootKey,
commandPath: commandPath,
token: token,
fileManager: fileManager
)
case let .openCodeJSON(configURL):
try setOpenCodeServer(
at: configURL,
commandPath: commandPath,
token: token,
fileManager: fileManager
)
case let .codexCLI(executableURL, _):
try addViaCLI(
executableURL: executableURL,
Expand Down Expand Up @@ -2084,6 +2128,58 @@ public enum MCPClientSetup {
try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: target.path)
}

private static func setOpenCodeServer(
at target: URL,
commandPath: String,
token: String,
fileManager: FileManager
) throws {
let definition: [String: Any] = [
"type": "local",
"command": [commandPath],
"environment": ["TORROMAIL_TOKEN": token],
"enabled": true
]
let definitionData = try JSONSerialization.data(withJSONObject: definition, options: [.sortedKeys])
guard let definitionJSON = String(data: definitionData, encoding: .utf8) else {
throw Failure("Could not build the OpenCode configuration.")
}
let existing: Data
if fileManager.fileExists(atPath: target.path) {
do {
existing = try Data(contentsOf: target)
} catch {
throw Failure("The existing configuration could not be read.")
}
} else {
existing = Data()
}
var document = JSON5Document(data: existing.isEmpty ? Data("{}".utf8) : existing)
try document.set(path: openCodeServerPath, jsonValue: definitionJSON)
try fileManager.createDirectory(
at: target.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try document.data.write(to: target, options: .atomic)
}

private static func removeOpenCodeServer(
at target: URL,
fileManager: FileManager
) throws {
guard fileManager.fileExists(atPath: target.path) else { return }
let existing: Data
do {
existing = try Data(contentsOf: target)
} catch {
throw Failure("The existing configuration could not be read.")
}
guard !existing.isEmpty else { return }
var document = JSON5Document(data: existing)
try document.remove(path: openCodeServerPath)
try document.data.write(to: target, options: .atomic)
}

private static func removeOpenClawServer(
at target: URL,
fileManager: FileManager
Expand Down Expand Up @@ -2123,16 +2219,6 @@ public enum MCPClientSetup {
"command": commandPath,
"env": ["TORROMAIL_TOKEN": token]
]
// OpenCode's schema: a `local` server whose command is a list and
// whose variables are called `environment`.
if rootKey == "mcp" {
entry = [
"type": "local",
"command": [commandPath],
"environment": ["TORROMAIL_TOKEN": token],
"enabled": true
]
}
// VS Code's `servers` schema tags the transport; the `mcpServers`
// clients infer stdio from `command` and reject an unknown key here.
if rootKey == "servers" { entry["type"] = "stdio" }
Expand Down Expand Up @@ -2602,12 +2688,14 @@ extension MCPClientSetup {
fileManager: FileManager = .default
) throws {
switch client.setup {
case let .mcpServersJSON(configURL), let .serversJSON(configURL), let .openCodeJSON(configURL):
case let .mcpServersJSON(configURL), let .serversJSON(configURL):
try removeFromJSONConfig(
at: configURL,
rootKey: client.setup.jsonRootKey,
fileManager: fileManager
)
case let .openCodeJSON(configURL):
try removeOpenCodeServer(at: configURL, fileManager: fileManager)
case let .codexCLI(executableURL, _):
try removeViaCLI(executableURL: executableURL, extraArguments: [])
case let .claudeCodeCLI(executableURL, _):
Expand Down
15 changes: 12 additions & 3 deletions apps/TorroMailApp/Tests/TorroMailKitContract/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1995,9 +1995,18 @@ for (index, mergeCase) in mergeCases.enumerated() {
continue
}
require(!failed, "shared client case succeeds: \(caseName)")
let written = (try? Data(contentsOf: configURL))
.flatMap { try? JSONSerialization.jsonObject(with: $0) } as? NSDictionary
require(written == mergeCase["expected"] as? NSDictionary, "shared client case: \(caseName)")
let writtenText = (try? String(contentsOf: configURL, encoding: .utf8)) ?? ""
if let expected = mergeCase["expected"] as? NSDictionary {
let written = (try? Data(contentsOf: configURL))
.flatMap { try? JSONSerialization.jsonObject(with: $0) } as? NSDictionary
require(written == expected, "shared client case: \(caseName)")
}
for expected in mergeCase["expected_text_contains"] as? [String] ?? [] {
require(writtenText.contains(expected), "shared client case preserves/adds \(expected): \(caseName)")
}
for excluded in mergeCase["expected_text_excludes"] as? [String] ?? [] {
require(!writtenText.contains(excluded), "shared client case removes \(excluded): \(caseName)")
}
require(
MCPClientSetup.isConfigured(sharedClient) == !removing,
"shared client case reads back as \(removing ? "not configured" : "configured"): \(caseName)"
Expand Down
32 changes: 30 additions & 2 deletions contracts/client-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -175,11 +175,39 @@
"name": "opencode keeps its servers under `mcp`, as local entries with a command list, beside its own settings",
"root_key": "mcp"
},
{
"action": "add",
"existing": "{\n // my settings must survive\n \"model\": \"x\",\n \"mcp\": {\n \"other\": { \"type\": \"remote\", \"url\": \"https://example.com/mcp\" },\n },\n}",
"expected_text_contains": [
"// my settings must survive",
"\"model\": \"x\"",
"\"other\": { \"type\": \"remote\"",
"\"torromail\"",
"\"TORROMAIL_TOKEN\":\"torro_cursor_0123abcd\""
],
"name": "opencode JSONC keeps comments, trailing commas, existing servers, and gains TorroMail",
"root_key": "mcp"
},
{
"action": "remove",
"existing": "{\n // my settings must survive\n \"model\": \"x\",\n \"mcp\": {\n \"other\": { \"type\": \"remote\", \"url\": \"https://example.com/mcp\" },\n \"torromail\": {\"type\":\"local\",\"command\":[\"/Applications/TorroMail.app/Contents/MacOS/torromail-mcp\"],\"environment\":{\"TORROMAIL_TOKEN\":\"torro_cursor_0123abcd\"},\"enabled\":true},\n },\n}",
"expected_text_contains": [
"// my settings must survive",
"\"model\": \"x\"",
"\"other\": { \"type\": \"remote\""
],
"expected_text_excludes": [
"\"torromail\"",
"torro_cursor_0123abcd"
],
"name": "removing from opencode JSONC preserves comments, trailing commas, and other servers",
"root_key": "mcp"
},
{
"action": "add",
"error": true,
"existing": "{\n // my settings\n \"model\": \"x\"\n}",
"name": "an opencode config with comments in it is left alone",
"existing": "{\n // an unfinished object is still malformed\n \"mcp\": {",
"name": "malformed opencode JSONC is rejected and left untouched",
"root_key": "mcp"
},
{
Expand Down
Loading
Loading