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
34 changes: 21 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,14 +163,15 @@ the bundle shape, embedded Sparkle framework, and entitlements are continuously
checked. Ad-hoc packages add `disable-library-validation` only for the local
signature so the embedded Sparkle framework can load; set
`AGENTD_ADHOC_DISABLE_LIBRARY_VALIDATION=0` to test without that local escape
hatch. Release builds inject `AGENTD_SPARKLE_FEED_URL` and
`AGENTD_SPARKLE_PUBLIC_ED_KEY` so the menu bar's "Check for Updates..." action
can use Sparkle. When `AGENTD_SPARKLE_DOWNLOAD_URL` is set, the package script
signs the final zip with Sparkle EdDSA, writes `dist/appcast.xml`, and verifies
that the appcast enclosure URL, signature, version, and archive length match the
packaged artifact. Release workflow builds also enable Sparkle signed-feed
validation so compromised update metadata cannot redirect users to a different
archive. To dry-run Sparkle's update discovery against a packaged bundle, use
hatch. Packaged clients include the signed GitHub Sparkle feed by default, with
automatic checks enabled and signed-feed validation required, so new installs
receive the notarized release channel without extra local configuration.
Release builds may still inject `AGENTD_SPARKLE_FEED_URL` and
`AGENTD_SPARKLE_PUBLIC_ED_KEY` to override the embedded feed/key. When
`AGENTD_SPARKLE_DOWNLOAD_URL` is set, the package script signs the final zip
with Sparkle EdDSA, writes `dist/appcast.xml`, and verifies that the appcast
enclosure URL, signature, version, and archive length match the packaged
artifact. To dry-run Sparkle's update discovery against a packaged bundle, use
`scripts/sparkle_update_probe.sh` with `AGENTD_SPARKLE_PROBE_FEED_URL` or a
local `dist/appcast.xml`.

Expand Down Expand Up @@ -233,7 +234,9 @@ does not install LaunchAgent plists.

agentd reads and writes `~/.evalops/agentd/config.json`. Important defaults:

- `localOnly: true`
- `organizationId: "evalops"`
- `endpoint: "https://chronicle.evalops.dev/chronicle.v1.ChronicleService/SubmitBatch"`
- `localOnly: false`
- `captureFps: 1.0`
- `idleFps: 0.2`
- `idleThresholdSeconds: 60`
Expand All @@ -259,9 +262,8 @@ agentd reads and writes `~/.evalops/agentd/config.json`. Important defaults:
- `maxOcrTextChars: 4096`
- `maxBatchAgeDays: 7`
- `maxBatchBytes: 536870912`
- `encryptLocalBatches: false` in local-only mode, `true` in remote or Secret
Broker mode when omitted
- `auth: { "mode": "none" }`
- `encryptLocalBatches: true` in managed or Secret Broker mode when omitted
- `auth: { "mode": "bearer", "keychainService": "dev.evalops.agentd", "keychainAccount": "chronicle" }`

Optional `metadata` entries are copied into every Chronicle `FrameBatch` and
Secret Broker wrap request. Use this for non-secret correlation IDs such as
Expand All @@ -274,6 +276,12 @@ canonical values such as invalid `traceparent` strings. This mirrors the
Platform envelope contract and Maestro emitter shape tracked in
evalops/platform#1201 and evalops/maestro-internal#1538.

New configs default to managed Chronicle mode. The onboarding/install path must
place the bearer token in Keychain service `dev.evalops.agentd`, account
`chronicle`; the token's organization must match `organizationId`. Set
`localOnly: true` to opt into a local dev client, which defaults back to the
loopback Chronicle endpoint and `auth: { "mode": "none" }`.

Remote mode requires `localOnly: false`, an HTTPS or loopback endpoint, and an
auth mode. Bearer auth references a Keychain item:

Expand Down Expand Up @@ -321,7 +329,7 @@ RPC URLs from `endpoint`. For example:

```json
{
"endpoint": "https://chronicle.example.com/chronicle.v1.ChronicleService/SubmitBatch",
"endpoint": "https://chronicle.evalops.dev/chronicle.v1.ChronicleService/SubmitBatch",
"localOnly": false,
"auth": {
"mode": "bearer",
Expand Down
37 changes: 28 additions & 9 deletions Sources/agentd/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,20 @@ enum AuthMode: Sendable, Codable, Equatable {
}

struct AgentConfig: Codable, Sendable {
static let defaultManagedOrganizationId = "evalops"
static let defaultManagedEndpoint = URL(
string: "https://chronicle.evalops.dev/chronicle.v1.ChronicleService/SubmitBatch")!
static let defaultLocalEndpoint = URL(
string: "http://127.0.0.1:8787/chronicle.v1.ChronicleService/SubmitBatch")!
static let defaultManagedKeychainService = "dev.evalops.agentd"
static let defaultManagedKeychainAccount = "chronicle"
static var defaultManagedAuth: AuthMode {
.bearer(
keychainService: defaultManagedKeychainService,
keychainAccount: defaultManagedKeychainAccount
)
}

var deviceId: String
var organizationId: String
var workspaceId: String?
Expand Down Expand Up @@ -334,18 +348,21 @@ struct AgentConfig: Codable, Sendable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
deviceId = try container.decode(String.self, forKey: .deviceId)
let decodedLocalOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false
organizationId =
try container.decodeIfPresent(String.self, forKey: .organizationId)
?? container.decodeIfPresent(String.self, forKey: .orgId)
?? "local"
?? (decodedLocalOnly ? "local" : Self.defaultManagedOrganizationId)
workspaceId = try container.decodeIfPresent(String.self, forKey: .workspaceId)
userId = try container.decodeIfPresent(String.self, forKey: .userId)
projectId = try container.decodeIfPresent(String.self, forKey: .projectId)
repository = try container.decodeIfPresent(String.self, forKey: .repository)
metadata = EvalOpsContextMetadata.clean(
try container.decodeIfPresent([String: String].self, forKey: .metadata) ?? [:]
)
endpoint = try container.decode(URL.self, forKey: .endpoint)
endpoint =
try container.decodeIfPresent(URL.self, forKey: .endpoint)
?? (decodedLocalOnly ? Self.defaultLocalEndpoint : Self.defaultManagedEndpoint)
allowedBundleIds =
try container.decodeIfPresent([String].self, forKey: .allowedBundleIds)
?? Self.defaultAllowedBundleIds
Expand Down Expand Up @@ -425,8 +442,10 @@ struct AgentConfig: Codable, Sendable {
try container.decodeIfPresent(Bool.self, forKey: .sparseFrameIncludeOcrText) ?? false
sparseFrameVisualRedactionEnabled =
try container.decodeIfPresent(Bool.self, forKey: .sparseFrameVisualRedactionEnabled) ?? false
localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? true
auth = try container.decodeIfPresent(AuthMode.self, forKey: .auth) ?? .none
localOnly = decodedLocalOnly
auth =
try container.decodeIfPresent(AuthMode.self, forKey: .auth)
?? (localOnly ? .none : Self.defaultManagedAuth)
secretBroker = try container.decodeIfPresent(SecretBrokerConfig.self, forKey: .secretBroker)
encryptLocalBatches =
try container.decodeIfPresent(Bool.self, forKey: .encryptLocalBatches)
Expand Down Expand Up @@ -551,8 +570,8 @@ struct AgentConfig: Codable, Sendable {
static func fallback() -> AgentConfig {
AgentConfig(
deviceId: ProcessInfo.processInfo.globallyUniqueString,
organizationId: "local",
endpoint: URL(string: "http://127.0.0.1:8787/chronicle.v1.ChronicleService/SubmitBatch")!,
organizationId: defaultManagedOrganizationId,
endpoint: defaultManagedEndpoint,
allowedBundleIds: defaultAllowedBundleIds,
deniedBundleIds: defaultDeniedBundleIds,
deniedPathPrefixes: defaultDeniedPathPrefixes,
Expand Down Expand Up @@ -590,9 +609,9 @@ struct AgentConfig: Codable, Sendable {
sparseFrameRetentionHours: 6,
sparseFrameIncludeOcrText: false,
sparseFrameVisualRedactionEnabled: false,
localOnly: true,
encryptLocalBatches: false,
auth: .none,
localOnly: false,
encryptLocalBatches: true,
auth: defaultManagedAuth,
secretBroker: nil
)
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/agentd/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ final class AppController {
maxBatchBytes: cfg.maxBatchBytes,
maxBatchAgeDays: cfg.maxBatchAgeDays,
deviceId: cfg.deviceId,
encryptLocalBatches: false
encryptLocalBatches: cfg.encryptLocalBatches
)
}
self.submitter = submitter
Expand Down
22 changes: 22 additions & 0 deletions Tests/agentdTests/SparkleUpdaterConfigurationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,28 @@ final class SparkleUpdaterConfigurationTests: XCTestCase {
XCTAssertTrue(presentation.isConfigured)
}

func testBundleInfoPlistEnablesSignedReleaseUpdatesByDefault() throws {
let plistURL = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("support/Info.plist")
let info = try XCTUnwrap(NSDictionary(contentsOf: plistURL) as? [String: Any])

let cfg = SparkleUpdaterConfiguration.read(from: info)

XCTAssertTrue(cfg.isConfigured)
XCTAssertEqual(
cfg.feedURL?.absoluteString,
"https://github.com/evalops/agentd/releases/latest/download/appcast.xml"
)
XCTAssertEqual(cfg.publicEDKey, "iovo6pS38VtTLFtWFsJLWXqvzmJrLmPQc0/TYFwFAF4=")
XCTAssertEqual(info["SUEnableAutomaticChecks"] as? Bool, true)
XCTAssertEqual(info["SUScheduledCheckInterval"] as? Int, 3600)
XCTAssertEqual(info["SURequireSignedFeed"] as? Bool, true)
XCTAssertEqual(info["SUVerifyUpdateBeforeExtraction"] as? Bool, true)
}

func testDisabledWithoutFeedOrPublicKey() {
let cfg = SparkleUpdaterConfiguration.read(from: [:])

Expand Down
33 changes: 33 additions & 0 deletions Tests/agentdTests/SubmitterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,39 @@ final class SubmitterTests: XCTestCase {
XCTAssertEqual(cfg.organizationId, "org_new")
}

func testAgentConfigDefaultsNewClientsToManagedChronicle() throws {
let data = """
{
"deviceId": "device_1"
}
""".data(using: .utf8)!

let cfg = try JSONDecoder().decode(AgentConfig.self, from: data)

XCTAssertFalse(cfg.localOnly)
XCTAssertEqual(cfg.organizationId, AgentConfig.defaultManagedOrganizationId)
XCTAssertEqual(cfg.endpoint, AgentConfig.defaultManagedEndpoint)
XCTAssertEqual(cfg.auth, AgentConfig.defaultManagedAuth)
XCTAssertTrue(cfg.encryptLocalBatches)
}

func testAgentConfigKeepsExplicitLocalOnlyConfigsLocalByDefault() throws {
let data = """
{
"deviceId": "device_1",
"localOnly": true
}
""".data(using: .utf8)!

let cfg = try JSONDecoder().decode(AgentConfig.self, from: data)

XCTAssertTrue(cfg.localOnly)
XCTAssertEqual(cfg.organizationId, "local")
XCTAssertEqual(cfg.endpoint, AgentConfig.defaultLocalEndpoint)
XCTAssertEqual(cfg.auth, .none)
XCTAssertFalse(cfg.encryptLocalBatches)
}

func testAgentConfigDecodesAndCleansMetadata() throws {
let data = """
{
Expand Down
26 changes: 14 additions & 12 deletions docs/release-update-channel.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,23 @@ The signed update-channel path is intentionally evidence-first:
8. Publish update metadata only after the archive checksum, Sparkle signature,
signing identity, notarization request id, and Gatekeeper output are recorded.

Sparkle is now the release update framework. Local and ad-hoc packages keep the
menu item disabled unless the package step injects both `SUFeedURL` and
`SUPublicEDKey`; this prevents a developer build from pointing at production
updates by accident. Ad-hoc packages also add `disable-library-validation` to
the local app signature so macOS can load the embedded Sparkle framework without
a Developer ID team identifier. Developer ID release packages use the normal
app entitlements and keep library validation enabled. A release package that
sets `AGENTD_SPARKLE_DOWNLOAD_URL` must also be notarized and must produce a
signed appcast.
Sparkle is now the release update framework. The bundled `support/Info.plist`
points new packages at the signed GitHub release appcast by default, enables
automatic checks, and requires signed update metadata. Ad-hoc packages still add
`disable-library-validation` to the local app signature so macOS can load the
embedded Sparkle framework without a Developer ID team identifier. Developer ID
release packages use the normal app entitlements and keep library validation
enabled. A release package that sets `AGENTD_SPARKLE_DOWNLOAD_URL` must also be
notarized and must produce a signed appcast.

Sparkle release configuration is injected at package time:
Sparkle release configuration can be overridden at package time:

- `AGENTD_SPARKLE_FEED_URL`: hosted appcast URL embedded as `SUFeedURL`.
- `AGENTD_SPARKLE_FEED_URL`: hosted appcast URL embedded as `SUFeedURL`; when
omitted, packages use
`https://github.com/evalops/agentd/releases/latest/download/appcast.xml`.
- `AGENTD_SPARKLE_PUBLIC_ED_KEY`: base64 public EdDSA key embedded as
`SUPublicEDKey`.
`SUPublicEDKey`; when omitted, packages use the committed release-channel
public key.
- `AGENTD_SPARKLE_DOWNLOAD_URL`: hosted HTTPS URL for the final `agentd.zip`;
when set, `scripts/package_app.sh` writes and verifies `dist/appcast.xml`.
- `AGENTD_SPARKLE_ED_KEY_FILE`: path to an exported Sparkle private EdDSA key
Expand Down
12 changes: 12 additions & 0 deletions support/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@
<string>0.1.0</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>SUFeedURL</key>
<string>https://github.com/evalops/agentd/releases/latest/download/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>iovo6pS38VtTLFtWFsJLWXqvzmJrLmPQc0/TYFwFAF4=</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>SUScheduledCheckInterval</key>
<integer>3600</integer>
<key>SURequireSignedFeed</key>
<true/>
<key>SUVerifyUpdateBeforeExtraction</key>
<true/>
<key>LSUIElement</key>
<true/>
<key>LSMinimumSystemVersion</key>
Expand Down