Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
3348636
Make the host, not the model, the capability boundary
zac Jul 26, 2026
6f0e702
Make adversarial number handling total instead of trapping
zac Jul 26, 2026
a6f866d
Stop fs.move/fs.copy from recursively deleting their destination
zac Jul 26, 2026
aa96ec0
Strip ambient authority from network.fetch
zac Jul 26, 2026
d8aa9da
Bound what a single execution can accumulate in memory
zac Jul 26, 2026
cdc991a
Fix two threading bugs in the permission and EventKit paths
zac Jul 26, 2026
226337a
Work through the P2 correctness and polish findings
zac Jul 26, 2026
8b84ae3
Stop multi-step eval scenarios from masking a failed step
zac Jul 26, 2026
f4748cc
Record the decision to keep swift-syntax in the core target
zac Jul 26, 2026
1ef45cf
Generate TypeScript declarations from the capability registry
zac Jul 26, 2026
afa3f07
Give the runtime a real timer loop and stop hiding swallowed failures
zac Jul 26, 2026
7f44ebb
Bound concurrent executions and cover the concurrent path with tests
zac Jul 26, 2026
7245335
Unfreeze the catalog and bring host providers toward built-in parity
zac Jul 26, 2026
c7e7a5d
Cover the EventKit serialization crashes, pin CI, record what is left
zac Jul 26, 2026
3cfcc91
Simplify the review-fix code: dedupe, drop dead code, tighten seams
zac Jul 26, 2026
f7db6c7
Teach agents the paradigm, not just the API surface
zac Jul 26, 2026
ed42fa2
Fix CI: iOS/visionOS build breaks and load-sensitive test bounds
zac Jul 27, 2026
7600de1
Fire timers in due order, and stop two tests starving on GCD
zac Jul 27, 2026
ffbf931
Fresh-look pass: fix two lifetime/concurrency bugs and tighten the rest
zac Sep 18, 2026
8cd8e45
Synchronize the sleep-interrupt test on the sleeper actually starting
zac Sep 18, 2026
1aac31a
Stop the test suite from starving itself on CI
zac Sep 18, 2026
bc3316d
Declare CodeMode as a direct dependency of the authoring tests
zac Sep 18, 2026
7d0de2c
Make the timer tests deterministic with an injectable clock
zac Sep 18, 2026
dfaae28
Close the source side of fs.move/fs.copy, and make overwrite non-dest…
zac Oct 1, 2026
4dbc8ae
Render TypeScript declarations as object types so reserved words are …
zac Oct 1, 2026
633a872
Stop the agent docs from spending tokens hosts can't budget
zac Oct 1, 2026
45a2ef5
Decode JavaScriptAPIReference without a dts key
zac Oct 1, 2026
ce747ee
Require PathPolicy.allowedRoots instead of defaulting it to empty
zac Oct 1, 2026
b8af8b2
Give search its own slots, and drop a dead branch from ExecutionSlots
zac Oct 1, 2026
d081195
Bring TODO.md and the README's API list up to date
zac Oct 1, 2026
11ab35a
Verify the watchdog on iOS, and make the suite honest about the keychain
zac Oct 1, 2026
624174f
Correct what the declaration docs claim, now that tsc has checked it
zac Oct 7, 2026
1b42e44
Compile the generated TypeScript declarations in CI
zac Oct 7, 2026
231a8d4
Bound timer delays before converting them, so a script can't crash th…
zac Oct 21, 2026
f8aa66f
Count hidden entries against the fs.copy limit
zac Oct 21, 2026
7860087
Validate exception line numbers, so a script can't crash the host wit…
zac Oct 21, 2026
3d17ebf
Mark the pre-merge verification done in TODO.md
zac Oct 21, 2026
89daac2
Compile scripts on their own, so they can't break out of the harness
zac Oct 21, 2026
bbba1dc
Rewrite the README as an overview, at about a third of the length
zac Oct 21, 2026
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
20 changes: 17 additions & 3 deletions .github/workflows/codemode-evals.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ jobs:
# unchanged main added no signal for these offline checks.
deterministic:
name: Deterministic evals
runs-on: macos-latest
# Pinned, not macos-latest: the package declares a macOS 15 floor, and a
# silently-rolling runner would move that floor without a code change.
runs-on: macos-15
steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -49,6 +51,18 @@ jobs:
- name: Test package
run: swift test

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"

# The generated TypeScript declarations go into model prompts and host
# type-checkers, so they are compiled for real: every platform surface and
# every per-capability fragment, under `tsc --strict`, TypeScript pinned.
# The Swift tests only catch failure shapes someone already thought of.
- name: Type-check generated TypeScript declarations
run: scripts/check-typescript-declarations.sh "$RUNNER_TEMP/codemode-dts"

- name: Build eval CLI
run: swift build --package-path Tools/CodeModeEval

Expand All @@ -57,7 +71,7 @@ jobs:

platform-build:
name: Build (${{ matrix.platform }})
runs-on: macos-latest
runs-on: macos-15
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -107,7 +121,7 @@ jobs:
# live+compare steps with a warning, instead of pretending or failing noisily.
llm-live:
name: LLM eval (live regression gate)
runs-on: macos-latest
runs-on: macos-15
needs: deterministic
if: github.event_name == 'workflow_dispatch'
env:
Expand Down
5 changes: 5 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ let package = Package(
.testTarget(
name: "CodeModeAuthoringTests",
dependencies: [
// Declared directly, not only via CodeModeAuthoring: these tests use
// CodeModeConfiguration/CodeModeAgentTools, and without the direct
// edge SwiftPM did not rebuild them when CodeMode's types changed
// layout — leaving stale objects that failed to link or crashed.
"CodeMode",
"CodeModeAuthoring",
"CodeModeMacros",
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
Expand Down
441 changes: 121 additions & 320 deletions README.md

Large diffs are not rendered by default.

118 changes: 97 additions & 21 deletions Sources/CodeMode/API/BridgeModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,15 @@ import Foundation
public struct CodeModeConfiguration: Sendable {
public var pathPolicy: any PathPolicy
public var networkAccessPolicy: NetworkAccessPolicy
/// Host-owned ceiling on the model-authored `allowedCapabilities` /
/// `allowedCapabilityKeys`. Defaults to `.unrestricted`; see `CapabilityGrant`.
public var capabilityGrant: CapabilityGrant
public var fileSystem: any CodeModeFileSystem
/// Byte ceilings applied by the filesystem bridge to `fs.read` / `fs.write`.
public var fileSystemLimits: FileSystemLimits
/// Bounds on retained result/log/event volume per execution, and on how
/// many executions run concurrently.
public var executionLimits: ExecutionLimits
public var artifactStore: any ArtifactStore
public var permissionBroker: any PermissionBroker
public var auditLogger: any AuditLogger
Expand All @@ -25,7 +33,10 @@ public struct CodeModeConfiguration: Sendable {
public init(
pathPolicy: any PathPolicy = DefaultPathPolicy(),
networkAccessPolicy: NetworkAccessPolicy = .standard,
capabilityGrant: CapabilityGrant = .unrestricted,
fileSystem: any CodeModeFileSystem = LocalCodeModeFileSystem(),
fileSystemLimits: FileSystemLimits = .standard,
executionLimits: ExecutionLimits = .standard,
artifactStore: any ArtifactStore = InMemoryArtifactStore(),
permissionBroker: any PermissionBroker = SystemPermissionBroker(),
auditLogger: any AuditLogger = SyncAuditLogger(),
Expand All @@ -46,7 +57,10 @@ public struct CodeModeConfiguration: Sendable {
) {
self.pathPolicy = pathPolicy
self.networkAccessPolicy = networkAccessPolicy
self.capabilityGrant = capabilityGrant
self.fileSystem = fileSystem
self.fileSystemLimits = fileSystemLimits
self.executionLimits = executionLimits
self.artifactStore = artifactStore
self.permissionBroker = permissionBroker
self.auditLogger = auditLogger
Expand Down Expand Up @@ -89,6 +103,10 @@ public struct JavaScriptAPIReference: Sendable, Codable, Equatable {
public var argumentHints: [String: String]
public var argumentConstraints: CapabilityArgumentConstraints
public var resultSummary: String
/// TypeScript declaration for this helper, generated from the fields above.
/// Return it from `searchJavaScriptAPI` to give the model real types rather
/// than a coarse `argumentTypes` map plus prose.
public var dts: String

public init(
capability: String,
Expand All @@ -103,7 +121,8 @@ public struct JavaScriptAPIReference: Sendable, Codable, Equatable {
argumentTypes: [String: CapabilityArgumentType],
argumentHints: [String: String],
argumentConstraints: CapabilityArgumentConstraints = .none,
resultSummary: String
resultSummary: String,
dts: String = ""
) {
self.capability = capability
self.capabilityKey = capabilityKey ?? CodeModeCapabilityKey(rawValue: capability)
Expand All @@ -118,6 +137,38 @@ public struct JavaScriptAPIReference: Sendable, Codable, Equatable {
self.argumentHints = argumentHints
self.argumentConstraints = argumentConstraints
self.resultSummary = resultSummary
self.dts = dts
}

private enum CodingKeys: String, CodingKey {
case capability, capabilityKey, builtInCapability, jsNames, summary, tags, example
case requiredArguments, optionalArguments, argumentTypes, argumentHints
case argumentConstraints, resultSummary, dts
}

/// `dts` is optional on the way in. References encoded before it existed —
/// cached search results, eval fixtures — would otherwise fail to decode with
/// `keyNotFound`; when it is absent, the declaration is regenerated from the
/// decoded fields rather than left empty.
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.capability = try container.decode(String.self, forKey: .capability)
self.capabilityKey = try container.decode(CodeModeCapabilityKey.self, forKey: .capabilityKey)
self.builtInCapability = try container.decodeIfPresent(CapabilityID.self, forKey: .builtInCapability)
self.jsNames = try container.decode([String].self, forKey: .jsNames)
self.summary = try container.decode(String.self, forKey: .summary)
self.tags = try container.decode([String].self, forKey: .tags)
self.example = try container.decode(String.self, forKey: .example)
self.requiredArguments = try container.decode([String].self, forKey: .requiredArguments)
self.optionalArguments = try container.decode([String].self, forKey: .optionalArguments)
self.argumentTypes = try container.decode([String: CapabilityArgumentType].self, forKey: .argumentTypes)
self.argumentHints = try container.decode([String: String].self, forKey: .argumentHints)
self.argumentConstraints = try container.decode(CapabilityArgumentConstraints.self, forKey: .argumentConstraints)
self.resultSummary = try container.decode(String.self, forKey: .resultSummary)
self.dts = try container.decodeIfPresent(String.self, forKey: .dts) ?? ""
if self.dts.isEmpty {
self.dts = TypeScriptDeclarations.declaration(for: self)
}
}
}

Expand All @@ -132,6 +183,13 @@ public struct JavaScriptAPISearchResponse: Sendable, Codable, Equatable {
}

public struct JavaScriptExecutionRequest: Sendable, Codable, Equatable {
/// Bounds advertised in `executeJavaScriptParameterSchema`. Values outside
/// them are clamped rather than rejected: a model that asks for a 10-minute
/// budget should get the ceiling and a running script, not a hard failure.
public static let minimumTimeoutMs = 1
public static let maximumTimeoutMs = 60_000
public static let defaultTimeoutMs = 10_000

public var code: String
public var allowedCapabilities: [CapabilityID]
public var allowedCapabilityKeys: [CodeModeCapabilityKey]
Expand All @@ -142,16 +200,20 @@ public struct JavaScriptExecutionRequest: Sendable, Codable, Equatable {
code: String,
allowedCapabilities: [CapabilityID],
allowedCapabilityKeys: [CodeModeCapabilityKey] = [],
timeoutMs: Int = 10_000,
timeoutMs: Int = JavaScriptExecutionRequest.defaultTimeoutMs,
context: ExecutionContext = .init()
) {
self.code = code
self.allowedCapabilities = allowedCapabilities
self.allowedCapabilityKeys = allowedCapabilityKeys
self.timeoutMs = timeoutMs
self.timeoutMs = Self.clampTimeoutMs(timeoutMs)
self.context = context
}

static func clampTimeoutMs(_ value: Int) -> Int {
min(max(value, minimumTimeoutMs), maximumTimeoutMs)
}

private enum CodingKeys: String, CodingKey {
case code
case allowedCapabilities
Expand Down Expand Up @@ -194,10 +256,13 @@ public struct JavaScriptExecutionRequest: Sendable, Codable, Equatable {
self.allowedCapabilities = capabilities

self.allowedCapabilityKeys = try container.decodeIfPresent([CodeModeCapabilityKey].self, forKey: .allowedCapabilityKeys) ?? []
self.timeoutMs = try Self.decodeTimeoutMs(from: container) ?? 10_000
self.timeoutMs = Self.clampTimeoutMs(try Self.decodeTimeoutMs(from: container) ?? Self.defaultTimeoutMs)
self.context = try container.decodeIfPresent(ExecutionContext.self, forKey: .context) ?? .init()
}

// Every conversion here is total. `Int.init(_: Double)` traps on non-finite
// and out-of-range input, and this runs on model-authored JSON before any
// script does — `{"timeoutMs": 1e300}` would kill the host process in-process.
private static func decodeTimeoutMs(from container: KeyedDecodingContainer<CodingKeys>) throws -> Int? {
guard container.contains(.timeoutMs), try !container.decodeNil(forKey: .timeoutMs) else {
return nil
Expand All @@ -206,18 +271,32 @@ public struct JavaScriptExecutionRequest: Sendable, Codable, Equatable {
return value
}
if let value = try? container.decode(Double.self, forKey: .timeoutMs) {
return Int(value)
return try requireRepresentable(value, in: container)
}
if let string = try? container.decode(String.self, forKey: .timeoutMs),
let value = Double(string.trimmingCharacters(in: .whitespaces)) {
return Int(value)
return try requireRepresentable(value, in: container)
}
throw DecodingError.dataCorruptedError(
forKey: .timeoutMs,
in: container,
debugDescription: "timeoutMs must be an integer or a numeric string."
)
}

private static func requireRepresentable(
_ value: Double,
in container: KeyedDecodingContainer<CodingKeys>
) throws -> Int {
guard let converted = JSONValue.exactInt(from: value) else {
throw DecodingError.dataCorruptedError(
forKey: .timeoutMs,
in: container,
debugDescription: "timeoutMs must be a finite number representable as an integer; received \(value)."
)
}
return converted
}
}

public enum JavaScriptExecutionEvent: Sendable, Equatable {
Expand Down Expand Up @@ -299,22 +378,32 @@ public final class JavaScriptExecutionCall: @unchecked Sendable {
public let events: AsyncStream<JavaScriptExecutionEvent>

private let resultTask: Task<JavaScriptExecutionResult, Error>
/// Keeps the execution alive while this handle is; the events stream holds
/// the same token. When both are gone the execution is cancelled, so a call
/// nobody can observe does not keep touching the filesystem, the network,
/// and system UI on the user's behalf.
private let observation: ExecutionObservationToken
private let cancelImpl: @Sendable () -> Void

init(
events: AsyncStream<JavaScriptExecutionEvent>,
resultTask: Task<JavaScriptExecutionResult, Error>,
observation: ExecutionObservationToken,
cancelImpl: @escaping @Sendable () -> Void
) {
self.events = events
self.resultTask = resultTask
self.observation = observation
self.cancelImpl = cancelImpl
}

public var result: JavaScriptExecutionResult {
get async throws {
let outcome = await withTaskCancellationHandler {
await waitForResultOutcome()
// `resultTask.result` already detaches the await from the calling
// task's cancellation, so the previous `Task.detached` per access
// bought nothing but an extra task.
await resultTask.result
} onCancel: {
self.cancel()
}
Expand All @@ -324,8 +413,7 @@ public final class JavaScriptExecutionCall: @unchecked Sendable {
return result
case let .failure(error as CodeModeToolError):
throw error
case let .failure(error as CancellationError):
_ = error
case .failure(is CancellationError):
throw CodeModeToolError(code: "CANCELLED", message: "Execution cancelled")
case let .failure(error):
throw error
Expand All @@ -337,18 +425,6 @@ public final class JavaScriptExecutionCall: @unchecked Sendable {
cancelImpl()
resultTask.cancel()
}

private func waitForResultOutcome() async -> Result<JavaScriptExecutionResult, Error> {
await withCheckedContinuation { continuation in
Task.detached {
do {
continuation.resume(returning: .success(try await self.resultTask.value))
} catch {
continuation.resume(returning: .failure(error))
}
}
}
}
}

public struct ExecutionContext: Sendable, Codable, Equatable {
Expand Down
10 changes: 8 additions & 2 deletions Sources/CodeMode/API/SystemAppleServiceClients.swift
Original file line number Diff line number Diff line change
Expand Up @@ -819,8 +819,14 @@ enum SystemMapsMapping {
private func openSystemURL(_ url: URL) -> Bool {
#if canImport(UIKit)
if Thread.isMainThread {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
return true
// `UIApplication.shared` and `open` are main-actor-isolated. We have
// already established we are on the main thread, so state that to the
// compiler rather than hopping — a hop here would return before the open
// has been requested.
return MainActor.assumeIsolated {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
return true
}
}
let semaphore = DispatchSemaphore(value: 0)
let opened = LockedBox(false)
Expand Down
8 changes: 1 addition & 7 deletions Sources/CodeMode/Bridges/AlarmBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,13 +139,7 @@ public final class AlarmBridge: @unchecked Sendable {
}

private func isoDate(_ text: String?) -> Date? {
guard let text else { return nil }
let formatter = ISO8601DateFormatter()
if let date = formatter.date(from: text) {
return date
}
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.date(from: text)
CodeModeDate.parse(text)
}

#if canImport(AlarmKit) && canImport(SwiftUI) && os(iOS)
Expand Down
7 changes: 7 additions & 0 deletions Sources/CodeMode/Bridges/BigTicketAppleBridges.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,12 @@ public final class SpeechBridge: @unchecked Sendable {
try ensurePermission(.speechRecognition, context: context)
let path = try requireNonEmptyString("path", in: arguments, capability: "speech.file.transcribe")
let resolved = try context.pathPolicy.resolve(path: path)
// The raw, script-supplied `path` is replaced rather than accompanied:
// leaving both in the dictionary made every host adapter one
// `arguments.string("path")` away from a path-policy bypass, with nothing
// in the code saying which key was the safe one.
var clientArguments = arguments
clientArguments["path"] = .string(resolved.path)
clientArguments["resolvedPath"] = .string(resolved.path)
try validateOptionalPositiveNumber("timeoutMs", in: arguments, capability: "speech.file.transcribe")
return try client.transcribeFile(arguments: clientArguments)
Expand Down Expand Up @@ -348,7 +353,9 @@ public final class PassKitBridge: @unchecked Sendable {
public func addPass(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue {
let path = try requireNonEmptyString("path", in: arguments, capability: "passkit.pass.add")
let resolved = try context.pathPolicy.resolve(path: path)
// As above: the raw path does not survive into the client dictionary.
var clientArguments = arguments
clientArguments["path"] = .string(resolved.path)
clientArguments["resolvedPath"] = .string(resolved.path)
return try client.addPass(arguments: clientArguments)
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/CodeMode/Bridges/CapabilityRegistrations+Core.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ extension DefaultCapabilityRegistrationBuilder {
descriptor: .init(
id: .networkFetch,
title: "Fetch HTTP resource",
summary: "Perform HTTP(S) requests through URLSession via a fetch-compatible API.",
summary: "Perform HTTP(S) requests through an isolated URLSession via a fetch-compatible API. Requests carry no app cookies or stored credentials, and credential headers (Cookie, Authorization, Proxy-*) are refused unless the host app permits them.",
tags: ["network", "http", "fetch"],
example: "await fetch('https://api.example.com/data').then(r => r.json())",
requiredArguments: ["url"],
Expand All @@ -29,7 +29,7 @@ extension DefaultCapabilityRegistrationBuilder {
argumentHints: [
"url": "Absolute HTTP(S) URL string.",
"options.method": "HTTP method; defaults to GET.",
"options.headers": "Object of header key/value string pairs.",
"options.headers": "Object of header key/value string pairs. Cookie, Authorization, and Proxy-* are refused unless the host app permits credential headers.",
"options.body": "UTF-8 request body string.",
"options.bodyBase64": "Base64-encoded request body. Mutually exclusive with options.body.",
"options.timeoutMs": "Request and bridge wait timeout in milliseconds; defaults to 30000.",
Expand Down
Loading
Loading