Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 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
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
8 changes: 5 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 Down Expand Up @@ -57,7 +59,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 +109,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
131 changes: 131 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ let tools = CodeModeAgentTools(
- `allowedHosts` restricts fetch to an explicit list (entries match the host and its subdomains, and deliberately allowlisted private hosts such as `localhost` are honored); `blockedHosts` refuses specific hosts; `NetworkAccessPolicy.permissive` restores unrestricted behavior.
- Matching is by URL host only; DNS resolution is not performed, so a public hostname that resolves to a private address is not detected. Hosts that need stricter guarantees should set `allowedHosts`.

Script traffic also carries no ambient authority:

- `network.fetch` runs on an isolated ephemeral `URLSession` with no cookie storage, no credential storage, and no shared cache — not `URLSession.shared`, which would let a script ride whatever the host app is already logged in to and let a `Set-Cookie` poison the app's cookie jar.
- Script-supplied `Cookie`, `Authorization`, and `Proxy-*` request headers are refused. Set `allowsCredentialHeaders: true` when scripts legitimately call an authenticated API.
- A host that passes its own `session` to `NetworkBridge` takes responsibility for this; requests still disable per-request cookie handling.

```swift
let tools = CodeModeAgentTools(
config: CodeModeConfiguration(
Expand All @@ -152,6 +158,79 @@ let tools = CodeModeAgentTools(
)
```

## Agent Guidance

The tool descriptions and the generated TypeScript tell a model *what the helpers
are called*. Neither tells it *what shape its work should take* — so an agent can
end up using `executeJavaScript` as a thin RPC, one call per operation, which is
the pattern this package exists to replace.

`CodeModeAgentGuidance` is the missing half. Pair it with `typeDeclarations()`:

```swift
let systemPrompt = CodeModeAgentGuidance.systemPrompt(.standard)
+ "\n\n"
+ tools.typeDeclarations()
```

Three tiers, each self-contained and additive — dropping to a smaller one loses
examples, never a rule:

| Tier | ~Tokens | Contents |
| --- | --- | --- |
| `.brief` | ~160 | The core idea: one call runs the whole task; request only the capabilities you use. |
| `.standard` | ~580 | Adds the search → write → repair workflow and a worked multi-step example. |
| `.full` | ~1000 | Adds partial-failure handling, the sequential-native-call reality, and the anti-pattern list. |

Or let a budget choose:

```swift
CodeModeAgentGuidance.systemPrompt(approximateTokenBudget: 400) // -> .standard
```

Token counts are estimated at four characters per token — a planning bound, not a
real tokenization. A budget too small for any tier still returns `.brief`, on the
grounds that some guidance is the difference between an agent that batches its
work and one that does not.

The `execution.*` and `fs.whole-job-one-script` eval scenarios grade this: a
transcript that splits one obvious job across several executions, or that asks
for capabilities it never calls, fails.

## TypeScript Declarations

The registry already knows argument names, types, optionality, enum constraints,
and hints. That metadata is emitted as TypeScript so the model writes code
against real declarations instead of a coarse type map plus prose:

```swift
let tools = CodeModeAgentTools()
print(tools.typeDeclarations()) // whole platform-filtered surface
print(tools.capabilities()) // every reference, each carrying `dts`
```

Every `JavaScriptAPIReference` carries a per-capability `dts`, so code-driven
search stays the filter and TypeScript becomes the payload:

```javascript
async () => {
return api.references
.filter(ref => ref.tags.includes("calendar"))
.map(ref => ref.dts)
.join("\n\n");
}
```

Constrained arguments become string-literal unions, dotted argument paths
(`options.timeoutMs`) become nested object types, and argument hints become doc
comments. Results are typed `CodeModeValue` — built-in bridges return untyped
JSON dictionaries, so a narrower result type would be a fiction; the prose result
summary is in the doc comment until per-capability result schemas exist.

The Node-compatibility globals (`fetch`, `fs.promises.*`, `path`, `console`) are
declared in a hand-authored preamble, because their positional calling convention
is not something the catalog can express.

## Search

`searchJavaScriptAPI` accepts `JavaScriptAPISearchRequest`:
Expand Down Expand Up @@ -215,11 +294,44 @@ async () => {

- `code`
- `allowedCapabilities`
- `allowedCapabilityKeys`
- `timeoutMs`
- `context`

It returns a `JavaScriptExecutionCall` immediately.

### Capability allowlists are model-authored; the grant is not

`allowedCapabilities` and `allowedCapabilityKeys` travel in the advertised tool
schema, so the *model* fills them in. They are a least-privilege declaration and
a useful audit signal, but on their own they are not a sandbox: a host that pipes
tool JSON straight into `executeJavaScript` gives the script whatever the script
asked for.

`CodeModeConfiguration.capabilityGrant` is the host-owned ceiling. The effective
set is always `requested ∩ granted`:

```swift
let tools = CodeModeAgentTools(
config: CodeModeConfiguration(
capabilityGrant: .only(
[.fsRead, .fsWrite, .networkFetch],
capabilityKeys: ["myapp.tasks.complete"]
)
)
)
```

It defaults to `.unrestricted` for source compatibility. Anything the request
declares and the grant withholds fails with `CAPABILITY_DENIED`, is reported to
the model as not repairable by retrying, and is recorded as a
`CAPABILITY_WITHHELD_BY_HOST` diagnostic.

The two allowlists are strictly disjoint. Built-in `CapabilityID`s are granted
only by `allowedCapabilities`; `allowedCapabilityKeys` accepts arbitrary strings
and reaches custom providers only, so it cannot be used to spell a built-in past
a host that vets the typed field.

Cross-platform privileged helpers are installed under `apple.*`. Platform-specific helpers are installed only where supported, for example `ios.alarm.*` on iOS hosts that support AlarmKit.

`apple.location.requestPermission()` is currently exposed only on iOS hosts. Other Apple platforms can expose `apple.location.*` helpers when supported, but the explicit permission-request helper is intentionally hidden outside iOS for now.
Expand All @@ -242,6 +354,25 @@ System UI helpers are installed only on supported UI platforms. Shared iOS/visio
- on failure it throws `CodeModeToolError`
- `call.cancel()` interrupts in-flight JavaScript

### Timers and the event loop

`setTimeout`/`clearTimeout` are real: callbacks are deferred, delays are honoured,
`clearTimeout` cancels, and an error thrown inside a callback becomes a
`TIMER_CALLBACK_ERROR` diagnostic rather than propagating to whoever called
`setTimeout`. So `await new Promise(r => setTimeout(r, 1000))` — the standard
backoff — actually waits instead of spinning. A long wait remains cancellable and
is still bounded by `timeoutMs`.

There is no I/O event loop yet: the bridge ABI is synchronous, so native calls run
one at a time and `Promise.all` over several helpers completes them sequentially.
Because a timer is the only thing that can advance a pending program, a promise
with no resolve path and no queued timer is provably unsettleable and fails
immediately with `JS_RUNTIME_ERROR` instead of running out the clock.

Bridge calls that fail while the script still returns successfully — typically a
forgotten `await`, which silently discards the rejection — produce a
`BRIDGE_FAILURES_NOT_SURFACED` warning diagnostic.

### Timeout and cancellation

`timeoutMs` and `cancel()` are enforced preemptively: a JavaScriptCore execution
Expand Down
83 changes: 64 additions & 19 deletions Sources/CodeMode/API/BridgeModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ 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 for a single execution.
public var executionLimits: ExecutionLimits
public var artifactStore: any ArtifactStore
public var permissionBroker: any PermissionBroker
public var auditLogger: any AuditLogger
Expand All @@ -25,7 +32,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 +56,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 +102,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 +120,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 +136,7 @@ public struct JavaScriptAPIReference: Sendable, Codable, Equatable {
self.argumentHints = argumentHints
self.argumentConstraints = argumentConstraints
self.resultSummary = resultSummary
self.dts = dts
}
}

Expand All @@ -132,6 +151,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 +168,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 +224,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 +239,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 @@ -314,7 +361,10 @@ public final class JavaScriptExecutionCall: @unchecked Sendable {
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 +374,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 @@ -338,16 +387,12 @@ public final class JavaScriptExecutionCall: @unchecked Sendable {
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))
}
}
}
/// A dropped call must not keep running: without this, an execution whose
/// handle nobody holds continues to completion in the background, still
/// touching the filesystem, the network, and system UI on the user's behalf.
deinit {
cancelImpl()
resultTask.cancel()
}
}

Expand Down
Loading
Loading