diff --git a/.gitattributes b/.gitattributes index f5cc60d6f29..1579104b390 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,6 +5,8 @@ crates/fractal-client/src/generated.rs linguist-generated=true crates/fractal-client/src/contract.rs linguist-generated=true crates/fractal-client/src/lib.rs linguist-generated=true crates/fractal-client/src/notice_descriptors.generated.rs linguist-generated=true +crates/fractal-schema/src/client_control.rs linguist-generated=true +fixtures/clients/client-control-v1.golden.json linguist-generated=true clients/typescript/fractal-client/*.generated.ts linguist-generated=true clients/swift/FractalClient/Sources/FractalClient/*.generated.swift linguist-generated=true clients/swift/FractalClient/Sources/FractalClient/Models.swift linguist-generated=true diff --git a/clients/swift/FractalClient/Sources/FractalClient/Client.swift b/clients/swift/FractalClient/Sources/FractalClient/Client.swift index 893a518c303..df611aa1dbb 100644 --- a/clients/swift/FractalClient/Sources/FractalClient/Client.swift +++ b/clients/swift/FractalClient/Sources/FractalClient/Client.swift @@ -436,6 +436,7 @@ public actor FractalClient { /// Issue one expiring foreground command, without durable receipt or replay. public func deviceCommand(_ command: DeviceCommandRequest) async throws -> Envelope { try await post("v1/client/device-commands", command) } public func deviceResult(_ result: DeviceResult) async throws -> Envelope { try await post("v1/client/device-results", result) } + public func clientTestRevoke(device: String) async throws -> JSONValue { try await post("v1/client/test-clients/revoke", ["device": device]) } public func beginPairing(deviceName: String, personID: String) async throws -> Envelope { try await post("v1/client/pairings", PersonPairingBegin(apiVersion: fractalClientAPIVersion, deviceName: deviceName, personID: personID)) } public func beginPairing(_ request: PairingBegin) async throws -> Envelope { try await post("v1/client/pairings", request) } diff --git a/clients/swift/FractalClient/Sources/FractalClient/ClientControl.generated.swift b/clients/swift/FractalClient/Sources/FractalClient/ClientControl.generated.swift new file mode 100644 index 00000000000..4f6ed189984 --- /dev/null +++ b/clients/swift/FractalClient/Sources/FractalClient/ClientControl.generated.swift @@ -0,0 +1,120 @@ +// Generated by cargo run -p fractal-client-codegen; DO NOT EDIT. +// Source: crates/fractal-client-codegen/templates/ClientControl.swift.in. +import Foundation +import CryptoKit + +public let clientResultPurpose = "client.device-result.v1" +public let clientResultSignedFields = ["purpose", "command_kind", "device", "session", "grant_id", "run_id", "command_id", "outcome", "error_code", "visible_state_sha256"] +public enum ClientControlRefusal: String, Codable, Sendable { + case deviceNotTestClient = "device-not-test-client", grantMissing = "grant-missing", grantExpired = "grant-expired" + case screenNotGranted = "screen-not-granted", verbNotGranted = "verb-not-granted", budgetExhausted = "budget-exhausted", actorNotGranted = "actor-not-granted" +} +public struct ClientTestEnrollment: Codable, Sendable { + public var device: String; public var kind: String; public var enrolledBy: String + public var consentRef: String; public var packetDigest: String; public var host: String; public var instance: String + public var pairingSubject: String; public var publicKey: String; public var session: String +} +public struct ClientTestRevocation: Codable, Sendable { + public var device: String; public var enrollmentId: String; public var revokedBy: String +} +public struct ClientCommandReceipt: Codable, Sendable { + public var commandId: String; public var terminalState: DeviceCommandOutcome + public var issuer: String; public var device: String; public var session: String? + public var grantId: String?; public var runId: String?; public var commandKind: String + public var screen: String?; public var reason: ClientControlRefusal? + public var verifiedResultSignature: DeviceSignature?; public var signedResult: ClientResultBinding? + private enum CodingKeys: String, CodingKey { case commandId, terminalState, issuer, device, session, grantId, runId, commandKind, screen, reason, verifiedResultSignature, signedResult } + public func encode(to encoder: Encoder) throws { + var box = encoder.container(keyedBy: CodingKeys.self) + try box.encode(commandId, forKey: .commandId); try box.encode(terminalState, forKey: .terminalState) + try box.encode(issuer, forKey: .issuer); try box.encode(device, forKey: .device); try box.encodeIfPresent(session, forKey: .session) + try box.encodeIfPresent(grantId, forKey: .grantId); try box.encodeIfPresent(runId, forKey: .runId); try box.encode(commandKind, forKey: .commandKind) + try box.encodeIfPresent(screen, forKey: .screen); try box.encodeIfPresent(reason, forKey: .reason) + if let verifiedResultSignature { try box.encode(verifiedResultSignature, forKey: .verifiedResultSignature) } + else { try box.encodeNil(forKey: .verifiedResultSignature) } + if let signedResult { try box.encode(signedResult, forKey: .signedResult) } + else { try box.encodeNil(forKey: .signedResult) } + } +} +public struct ClientVisibleItem: Codable, Sendable, Equatable { + public var id: String; public var title: String; public var body: String + public init(id: String, title: String, body: String) { self.id = id; self.title = title; self.body = body } +} +public struct ClientVisibleState: Codable, Sendable, Equatable { + public var route: String; public var screen: String; public var visible_item_ids: [String]; public var opened_item: ClientVisibleItem? + public init(route: String, screen: String, visible_item_ids: [String], opened_item: ClientVisibleItem?) { + self.route = route; self.screen = screen; self.visible_item_ids = visible_item_ids; self.opened_item = opened_item + } + private enum CodingKeys: String, CodingKey { case route, screen, visible_item_ids, opened_item } + public func encode(to encoder: Encoder) throws { + var box = encoder.container(keyedBy: CodingKeys.self) + try box.encode(route, forKey: .route); try box.encode(screen, forKey: .screen) + try box.encode(visible_item_ids, forKey: .visible_item_ids) + if let opened_item { try box.encode(opened_item, forKey: .opened_item) } + else { try box.encodeNil(forKey: .opened_item) } + } +} +public struct ClientResultBinding: Codable, Sendable, Equatable { + public var purpose: String; public var command_kind: String; public var device: String; public var session: String + public var grant_id: String; public var run_id: String; public var command_id: String; public var outcome: String + public var error_code: String?; public var visible_state: ClientVisibleState + public init(purpose: String = clientResultPurpose, command_kind: String, device: String, session: String, grant_id: String, run_id: String, command_id: String, outcome: String, error_code: String?, visible_state: ClientVisibleState) { + self.purpose = purpose; self.command_kind = command_kind; self.device = device; self.session = session + self.grant_id = grant_id; self.run_id = run_id; self.command_id = command_id; self.outcome = outcome + self.error_code = error_code; self.visible_state = visible_state + } + private enum CodingKeys: String, CodingKey { + case purpose, command_kind, device, session, grant_id, run_id, command_id, outcome, error_code, visible_state + } + public func encode(to encoder: Encoder) throws { + var box = encoder.container(keyedBy: CodingKeys.self) + try box.encode(purpose, forKey: .purpose); try box.encode(command_kind, forKey: .command_kind) + try box.encode(device, forKey: .device); try box.encode(session, forKey: .session) + try box.encode(grant_id, forKey: .grant_id); try box.encode(run_id, forKey: .run_id) + try box.encode(command_id, forKey: .command_id); try box.encode(outcome, forKey: .outcome) + if let error_code { try box.encode(error_code, forKey: .error_code) } + else { try box.encodeNil(forKey: .error_code) } + try box.encode(visible_state, forKey: .visible_state) + } +} +public struct ClientControlGrant: Codable, Sendable, Equatable { + public var grant_id: String; public var run_id: String; public var device: String; public var session: String; public var actor_agent: String + public var verbs: [String]; public var screens: [String]; public var max_commands: UInt64; public var expires_at: String; public var state: String + public var item_id: String? +} +private func clientResultQuote(_ text: String) -> String { + var result = "\"" + for scalar in text.unicodeScalars { + switch scalar.value { + case 0x22: result += "\\\"" + case 0x5c: result += "\\\\" + case 8: result += "\\b" + case 9: result += "\\t" + case 10: result += "\\n" + case 12: result += "\\f" + case 13: result += "\\r" + case 0...31: result += String(format: "\\u%04x", scalar.value) + default: result.unicodeScalars.append(scalar) + } + } + return result + "\"" +} +/// RFC 8785 JCS for the generated closed string-only visible-state schema. +public func visibleStateCanonicalJson(_ state: ClientVisibleState) -> String { + let item = state.opened_item.map { "{\"body\":" + clientResultQuote($0.body) + ",\"id\":" + clientResultQuote($0.id) + ",\"title\":" + clientResultQuote($0.title) + "}" } ?? "null" + return "{\"opened_item\":" + item + ",\"route\":" + clientResultQuote(state.route) + ",\"screen\":" + clientResultQuote(state.screen) + ",\"visible_item_ids\":[" + state.visible_item_ids.map(clientResultQuote).joined(separator: ",") + "]}" +} +public func visibleStateDigest(_ state: ClientVisibleState) -> String { + SHA256.hash(data: Data(visibleStateCanonicalJson(state).utf8)).map { String(format: "%02x", $0) }.joined() +} +/// Sign with P256.Signing.PrivateKey.signature(for:).rawRepresentation (r||s). +/// This domain is never message.send authorization. Enforce active grant first. +public func clientResultCanonicalBytes(_ binding: ClientResultBinding) -> Data { + let fields: [(String, String?)] = [ + ("purpose", binding.purpose), ("command_kind", binding.command_kind), ("device", binding.device), + ("session", binding.session), ("grant_id", binding.grant_id), ("run_id", binding.run_id), + ("command_id", binding.command_id), ("outcome", binding.outcome), ("error_code", binding.error_code), + ("visible_state_sha256", visibleStateDigest(binding.visible_state)) + ] + return Data((["smallclaims-claim-fields-v1"] + fields.map { $0.0 + "=" + ($0.1.map(clientResultQuote) ?? "null") }).joined(separator: "\n").utf8) +} diff --git a/clients/swift/FractalClient/Sources/FractalClient/Models.swift b/clients/swift/FractalClient/Sources/FractalClient/Models.swift index 4b82447846e..66785dff35f 100644 --- a/clients/swift/FractalClient/Sources/FractalClient/Models.swift +++ b/clients/swift/FractalClient/Sources/FractalClient/Models.swift @@ -791,20 +791,23 @@ public struct DeviceCommandRequest: Codable, Sendable { public let apiVersion, device: String public let command: PhoneCommand public let ttlMS: UInt64? - public init(device: String, command: PhoneCommand, ttlMS: UInt64? = nil, apiVersion: String = "st3.client.v0") { - self.apiVersion = apiVersion; self.device = device; self.command = command; self.ttlMS = ttlMS + public let grantID: String? + public init(device: String, command: PhoneCommand, ttlMS: UInt64? = nil, grantID: String? = nil, apiVersion: String = "st3.client.v0") { + self.apiVersion = apiVersion; self.device = device; self.command = command; self.ttlMS = ttlMS; self.grantID = grantID } - private enum Keys: String, CodingKey { case apiVersion = "api_version", device, ttlMS = "ttl_ms" } + private enum Keys: String, CodingKey { case apiVersion = "api_version", device, ttlMS = "ttl_ms", grantID = "grant_id" } public init(from decoder: Decoder) throws { let box = try decoder.container(keyedBy: Keys.self) apiVersion = try box.decode(String.self, forKey: .apiVersion) device = try box.decode(String.self, forKey: .device) ttlMS = try box.decodeIfPresent(UInt64.self, forKey: .ttlMS) + grantID = try box.decodeIfPresent(String.self, forKey: .grantID) command = try PhoneCommand(from: decoder) } public func encode(to encoder: Encoder) throws { var box = encoder.container(keyedBy: Keys.self) try box.encode(apiVersion, forKey: .apiVersion); try box.encode(device, forKey: .device) + try box.encodeIfPresent(grantID, forKey: .grantID) try box.encodeIfPresent(ttlMS, forKey: .ttlMS); try command.encode(to: encoder) } } @@ -813,10 +816,12 @@ public struct DeviceResult: Codable, Sendable { public let outcome: DeviceCommandOutcome public let result: JSONValue? public let errorCode: String? - public init(commandID: String, outcome: DeviceCommandOutcome, result: JSONValue? = nil, errorCode: String? = nil) { - self.commandID = commandID; self.outcome = outcome; self.result = result; self.errorCode = errorCode + public let binding: ClientResultBinding? + public let signature: DeviceSignature? + public init(commandID: String, outcome: DeviceCommandOutcome, result: JSONValue? = nil, errorCode: String? = nil, binding: ClientResultBinding? = nil, signature: DeviceSignature? = nil) { + self.commandID = commandID; self.outcome = outcome; self.result = result; self.errorCode = errorCode; self.binding = binding; self.signature = signature } - enum CodingKeys: String, CodingKey { case commandID = "command_id", outcome, result, errorCode = "error_code" } + enum CodingKeys: String, CodingKey { case commandID = "command_id", outcome, result, errorCode = "error_code", binding, signature } } public struct DeviceCommand: Codable, Sendable, Identifiable { public let id, kind, revision, updatedAt, deviceID, sessionActor, issuedBy, issuedAt, expiresAt: String @@ -824,9 +829,14 @@ public struct DeviceCommand: Codable, Sendable, Identifiable { public let state: DeviceCommandState public let result: JSONValue? public let errorCode: String? + public let grantID, runID: String? + public let controlGrant: ClientControlGrant? + public let receiptClaimID: String? private enum Keys: String, CodingKey { case id, kind, revision, updatedAt = "updated_at", deviceID = "device_id", sessionActor = "session_actor" case issuedBy = "issued_by", issuedAt = "issued_at", expiresAt = "expires_at", state, result, errorCode = "error_code" + case grantID = "grant_id", runID = "run_id", controlGrant = "control_grant" + case receiptClaimID = "receipt_claim_id" } public init(from decoder: Decoder) throws { let box = try decoder.container(keyedBy: Keys.self) @@ -836,6 +846,9 @@ public struct DeviceCommand: Codable, Sendable, Identifiable { issuedBy = try box.decode(String.self, forKey: .issuedBy); issuedAt = try box.decode(String.self, forKey: .issuedAt) expiresAt = try box.decode(String.self, forKey: .expiresAt); state = try box.decode(DeviceCommandState.self, forKey: .state) result = try box.decodeIfPresent(JSONValue.self, forKey: .result); errorCode = try box.decodeIfPresent(String.self, forKey: .errorCode) + grantID = try box.decodeIfPresent(String.self, forKey: .grantID); runID = try box.decodeIfPresent(String.self, forKey: .runID) + controlGrant = try box.decodeIfPresent(ClientControlGrant.self, forKey: .controlGrant) + receiptClaimID = try box.decodeIfPresent(String.self, forKey: .receiptClaimID) command = try PhoneCommand(from: decoder, resource: true) } public func encode(to encoder: Encoder) throws { @@ -846,6 +859,9 @@ public struct DeviceCommand: Codable, Sendable, Identifiable { try box.encode(issuedBy, forKey: .issuedBy); try box.encode(issuedAt, forKey: .issuedAt) try box.encode(expiresAt, forKey: .expiresAt); try box.encode(state, forKey: .state) try box.encodeIfPresent(result, forKey: .result); try box.encodeIfPresent(errorCode, forKey: .errorCode) + try box.encodeIfPresent(grantID, forKey: .grantID); try box.encodeIfPresent(runID, forKey: .runID) + try box.encodeIfPresent(controlGrant, forKey: .controlGrant) + try box.encodeIfPresent(receiptClaimID, forKey: .receiptClaimID) try command.encode(to: encoder, resource: true) } } @@ -994,11 +1010,50 @@ public enum HarnessInvalidAnswerReason: String, Codable, Sendable { case unknown public struct HarnessInvalidAnswerDetails: Codable, Sendable { public let reason: HarnessInvalidAnswerReason } public enum HarnessNotAnswerableReason: String, Codable, Sendable { case adapterMissing = "adapter_missing", adapterUnavailable = "adapter_unavailable" } public struct HarnessNotAnswerableDetails: Codable, Sendable { public let reason: HarnessNotAnswerableReason } +public enum ClientControlGrantLifecycleState: String, Codable, Sendable { case started, ended, revoked } +public struct ClientControlGrantLifecycleResource: Codable, Sendable, Identifiable { + public let id, kind, revision, updatedAt, grantID, runID, device, session: String + public let itemID: String? + public let state: ClientControlGrantLifecycleState + public let reason: String + public let sequence: UInt64 + enum CodingKeys: String, CodingKey { + case id, kind, revision, updatedAt = "updated_at", grantID = "grant_id", runID = "run_id", itemID = "item_id", device, session, state, reason, sequence + } + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) + kind = try values.decode(String.self, forKey: .kind) + revision = try values.decode(String.self, forKey: .revision) + updatedAt = try values.decode(String.self, forKey: .updatedAt) + grantID = try values.decode(String.self, forKey: .grantID) + runID = try values.decode(String.self, forKey: .runID) + device = try values.decode(String.self, forKey: .device) + session = try values.decode(String.self, forKey: .session) + itemID = try values.decodeIfPresent(String.self, forKey: .itemID) + state = try values.decode(ClientControlGrantLifecycleState.self, forKey: .state) + reason = try values.decode(String.self, forKey: .reason) + sequence = try values.decode(UInt64.self, forKey: .sequence) + guard sequence >= 1 && sequence <= 9_007_199_254_740_991 else { + throw DecodingError.dataCorruptedError(forKey: .sequence, in: values, debugDescription: "Lifecycle sequence must be a positive JSON-safe integer") + } + } +} +/// Existing commands snapshot/changes frames; lifecycle rows share Resource decoding. +public struct ClientCommandsCollectionFrame: Codable, Sendable { + public let kind, id, collection: String + public let snapshot: Snapshot + public let items, upserts: [Resource]? + public let removes, order: [String]? + public let hasMore: Bool + enum CodingKeys: String, CodingKey { case kind, id, collection, snapshot, items, upserts, removes, order, hasMore = "has_more" } +} public enum Resource: Codable, Sendable, Identifiable { case harnessQuestion(HarnessQuestionResource) case connectedClient(ConnectedClientResource), usage(UsageResource) case summary(SummaryResource) case deviceCommand(DeviceCommand) + case clientControlGrantLifecycle(ClientControlGrantLifecycleResource) case attention(AttentionResource), message(MessageResource), launch(LaunchResource), launchVariant(LaunchVariantResource), launchDecision(LaunchDecisionResource), launchApproval(LaunchApprovalResource), mission(MissionResource), work(WorkResource), agent(AgentResource), runtime(RuntimeResource), observer(ObserverResource), subscription(SubscriptionResource), lane(LaneResource), machine(MachineResource), device(DeviceResource), operation(OperationResource), history(HistoryResource), session(SessionResource), glass(GlassResource), ownedSet(OwnedSetResource) /// A kind this client does not know, from a member newer than it: kept whole so an older /// client skips it or shows it plainly instead of failing the page it came in. @@ -1027,6 +1082,7 @@ public enum Resource: Codable, Sendable, Identifiable { case "machine": self = .machine(try MachineResource(from: decoder)) case "device": self = .device(try DeviceResource(from: decoder)) case "device-command": self = .deviceCommand(try DeviceCommand(from: decoder)) + case "client-control-grant-lifecycle": self = .clientControlGrantLifecycle(try ClientControlGrantLifecycleResource(from: decoder)) case "operation": self = .operation(try OperationResource(from: decoder)) case "history": self = .history(try HistoryResource(from: decoder)) case "session": self = .session(try SessionResource(from: decoder)) @@ -1058,6 +1114,7 @@ public enum Resource: Codable, Sendable, Identifiable { case .machine(let v): try v.encode(to: encoder) case .device(let v): try v.encode(to: encoder) case .deviceCommand(let v): try v.encode(to: encoder) + case .clientControlGrantLifecycle(let v): try v.encode(to: encoder) case .operation(let v): try v.encode(to: encoder) case .history(let v): try v.encode(to: encoder) case .session(let v): try v.encode(to: encoder) @@ -1067,7 +1124,7 @@ public enum Resource: Codable, Sendable, Identifiable { case .unknown(let v): try v.encode(to: encoder) } } - public var id: String { switch self { case .harnessQuestion(let v): v.id; case .connectedClient(let v): v.id; case .usage(let v): v.id; case .summary(let v): v.id; case .attention(let v): v.id; case .message(let v): v.id; case .launch(let v): v.id; case .launchVariant(let v): v.id; case .launchDecision(let v): v.id; case .launchApproval(let v): v.id; case .mission(let v): v.id; case .work(let v): v.id; case .agent(let v): v.id; case .runtime(let v): v.id; case .observer(let v): v.id; case .subscription(let v): v.id; case .lane(let v): v.id; case .machine(let v): v.id; case .device(let v): v.id; case .deviceCommand(let v): v.id; case .operation(let v): v.id; case .history(let v): v.id; case .session(let v): v.id; case .glass(let v): v.id; case .arrangement(let v): v.id; case .ownedSet(let v): v.id; case .unknown(let v): v.id } } + public var id: String { switch self { case .clientControlGrantLifecycle(let v): v.id; case .harnessQuestion(let v): v.id; case .connectedClient(let v): v.id; case .usage(let v): v.id; case .summary(let v): v.id; case .attention(let v): v.id; case .message(let v): v.id; case .launch(let v): v.id; case .launchVariant(let v): v.id; case .launchDecision(let v): v.id; case .launchApproval(let v): v.id; case .mission(let v): v.id; case .work(let v): v.id; case .agent(let v): v.id; case .runtime(let v): v.id; case .observer(let v): v.id; case .subscription(let v): v.id; case .lane(let v): v.id; case .machine(let v): v.id; case .device(let v): v.id; case .deviceCommand(let v): v.id; case .operation(let v): v.id; case .history(let v): v.id; case .session(let v): v.id; case .glass(let v): v.id; case .arrangement(let v): v.id; case .ownedSet(let v): v.id; case .unknown(let v): v.id } } } /// A resource of a kind this client does not model: its header, and every field as sent. public struct UnknownResource: Codable, Sendable, Identifiable { diff --git a/clients/swift/FractalClient/Tests/FractalClientTests/ClientControlLifecycleTests.swift b/clients/swift/FractalClient/Tests/FractalClientTests/ClientControlLifecycleTests.swift new file mode 100644 index 00000000000..f7344082452 --- /dev/null +++ b/clients/swift/FractalClient/Tests/FractalClientTests/ClientControlLifecycleTests.swift @@ -0,0 +1,43 @@ +import Foundation +import XCTest +@testable import FractalClient + +final class ClientControlLifecycleTests: XCTestCase { + func testSharedCommandsLifecycleDecoders() throws { + var root = URL(fileURLWithPath: #filePath) + for _ in 0..<6 { root.deleteLastPathComponent() } + let fixture = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: root.appendingPathComponent("fixtures/clients/client-control-lifecycle-v1.json"))) as? [String: Any]) + for row in try XCTUnwrap(fixture["resources"] as? [[String: Any]]) { + let bytes = try JSONSerialization.data(withJSONObject: row) + let resource = try JSONDecoder().decode(Resource.self, from: bytes) + guard case .clientControlGrantLifecycle(let lifecycle) = resource else { return XCTFail("Lifecycle decoded as unknown") } + XCTAssertEqual(lifecycle.grantID, row["grant_id"] as? String) + XCTAssertEqual(lifecycle.itemID, row["item_id"] as? String) + XCTAssertEqual(lifecycle.state.rawValue, row["state"] as? String) + XCTAssertEqual(lifecycle.sequence, (row["sequence"] as? NSNumber)?.uint64Value) + } + for row in try XCTUnwrap(fixture["frames"] as? [[String: Any]]) { + let frame = try JSONDecoder().decode(ClientCommandsCollectionFrame.self, from: JSONSerialization.data(withJSONObject: row)) + XCTAssertEqual(frame.collection, "commands") + let lifecycle = try XCTUnwrap((frame.items ?? frame.upserts)?.first) + guard case .clientControlGrantLifecycle = lifecycle else { return XCTFail("Frame lost typed lifecycle row") } + } + for trace in try XCTUnwrap(fixture["traces"] as? [[String: Any]]) { + for step in try XCTUnwrap(trace["steps"] as? [[String: Any]]) where step["expected"] as? String == "refuse-invalid-frame" { + let resource = try XCTUnwrap(step["resource"] as? [String: Any]) + XCTAssertThrowsError(try JSONDecoder().decode(Resource.self, from: JSONSerialization.data(withJSONObject: resource))) + var frame = try XCTUnwrap((fixture["frames"] as? [[String: Any]])?.first) + frame["items"] = [resource] + XCTAssertThrowsError(try JSONDecoder().decode(ClientCommandsCollectionFrame.self, from: JSONSerialization.data(withJSONObject: frame))) + } + } + for sequence in [UInt64(0), UInt64(9_007_199_254_740_992)] { + var resource = try XCTUnwrap((fixture["resources"] as? [[String: Any]])?.first) + resource["sequence"] = sequence + XCTAssertThrowsError(try JSONDecoder().decode(Resource.self, from: JSONSerialization.data(withJSONObject: resource))) + } + var invalid = try XCTUnwrap((fixture["resources"] as? [[String: Any]])?.first) + invalid["state"] = "future-state" + XCTAssertThrowsError(try JSONDecoder().decode(Resource.self, from: JSONSerialization.data(withJSONObject: invalid))) + } +} diff --git a/clients/swift/FractalClient/Tests/FractalClientTests/ClientControlTests.swift b/clients/swift/FractalClient/Tests/FractalClientTests/ClientControlTests.swift new file mode 100644 index 00000000000..91310745054 --- /dev/null +++ b/clients/swift/FractalClient/Tests/FractalClientTests/ClientControlTests.swift @@ -0,0 +1,37 @@ +import Foundation +import CryptoKit +import XCTest +@testable import FractalClient + +final class ClientControlTests: XCTestCase { + private func base64url(_ value: String) throws -> Data { + let padded = value.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + return try XCTUnwrap(Data(base64Encoded: padded + String(repeating: "=", count: (4 - padded.count % 4) % 4))) + } + private func binding(_ object: Any) throws -> ClientResultBinding { + try JSONDecoder().decode(ClientResultBinding.self, from: JSONSerialization.data(withJSONObject: object)) + } + func testSharedCanonicalAndSignatureVectors() throws { + var root = URL(fileURLWithPath: #filePath) + for _ in 0..<6 { root.deleteLastPathComponent() } + let fixture = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: root.appendingPathComponent("fixtures/clients/client-control-v1.golden.json"))) as? [String: Any]) + let publicKey = try P256.Signing.PublicKey(x963Representation: base64url(XCTUnwrap(fixture["public_key_sec1_base64url"] as? String))) + let message = Data(try XCTUnwrap(fixture["message_domain_bytes"] as? String).utf8) + let messageSignature = try P256.Signing.ECDSASignature(rawRepresentation: base64url(XCTUnwrap(fixture["message_domain_signature_p1363_base64url"] as? String))) + XCTAssertTrue(publicKey.isValidSignature(messageSignature, for: message)) + for vector in try XCTUnwrap(fixture["vectors"] as? [[String: Any]]) { + let input = try binding(XCTUnwrap(vector["input"])) + XCTAssertEqual(visibleStateCanonicalJson(input.visible_state), vector["canonical_visible_state"] as? String) + XCTAssertEqual(visibleStateDigest(input.visible_state), vector["visible_state_sha256"] as? String) + let bytes = clientResultCanonicalBytes(input) + XCTAssertEqual(bytes, Data(try XCTUnwrap(vector["canonical_bytes"] as? String).utf8)) + let signature = try P256.Signing.ECDSASignature(rawRepresentation: base64url(XCTUnwrap(vector["signature_p1363_base64url"] as? String))) + XCTAssertTrue(publicKey.isValidSignature(signature, for: bytes)) + for changed in try XCTUnwrap(vector["negative_bindings"] as? [Any]) { + XCTAssertFalse(publicKey.isValidSignature(signature, for: clientResultCanonicalBytes(try binding(changed)))) + } + XCTAssertFalse(publicKey.isValidSignature(signature, for: message)) + XCTAssertFalse(publicKey.isValidSignature(messageSignature, for: bytes)) + } + } +} diff --git a/clients/typescript/fractal-client/Client.generated.ts b/clients/typescript/fractal-client/Client.generated.ts index c6d8d44ebd4..94e943b3657 100644 --- a/clients/typescript/fractal-client/Client.generated.ts +++ b/clients/typescript/fractal-client/Client.generated.ts @@ -486,6 +486,10 @@ export class FractalClient { return this.request('POST', '/v1/client/device-results', result, undefined, undefined, requestOptions); } + async clientTestRevoke(device: string, requestOptions: RequestOptions = {}): Promise { + return this.request('POST', '/v1/client/test-clients/revoke', { device }, undefined, undefined, requestOptions); + } + async beginPairing(request: PairingBegin, requestOptions: RequestOptions = {}): Promise> { return this.request('POST', '/v1/client/pairings', request, undefined, undefined, requestOptions); } diff --git a/clients/typescript/fractal-client/ClientControl.generated.ts b/clients/typescript/fractal-client/ClientControl.generated.ts new file mode 100644 index 00000000000..9024c9c6c24 --- /dev/null +++ b/clients/typescript/fractal-client/ClientControl.generated.ts @@ -0,0 +1,60 @@ +// Generated by cargo run -p fractal-client-codegen; DO NOT EDIT. +// Source: crates/fractal-client-codegen/templates/ClientControl.ts.in. +export const CLIENT_RESULT_PURPOSE = 'client.device-result.v1'; +export const CLIENT_RESULT_SIGNED_FIELDS = ['purpose', 'command_kind', 'device', 'session', 'grant_id', 'run_id', 'command_id', 'outcome', 'error_code', 'visible_state_sha256'] as const; +export interface ClientVisibleItem { id: string; title: string; body: string } +export interface ClientVisibleState { route: string; screen: string; visible_item_ids: string[]; opened_item: ClientVisibleItem | null } +export interface ClientResultBinding { + purpose: string; command_kind: string; device: string; session: string; + grant_id: string; run_id: string; command_id: string; outcome: string; + error_code: string | null; visible_state: ClientVisibleState; +} +export interface ClientControlGrant { + grant_id: string; run_id: string; device: string; session: string; actor_agent: string; + verbs: string[]; screens: string[]; max_commands: number; expires_at: string; state: string; + item_id?: string; +} +export class ClientResultCanonicalizationError extends Error { + readonly code = 'invalid-unicode' as const; + constructor() { + super('Invalid Unicode in client result'); + this.name = 'ClientResultCanonicalizationError'; + } +} +function quote(value: string): string { + // RFC 8785 requires rejection of lone surrogates rather than replacement. + for (let i = 0; i < value.length; i++) { + const c = value.charCodeAt(i); + if (c >= 0xd800 && c <= 0xdbff) { + const next = value.charCodeAt(++i); + if (!(next >= 0xdc00 && next <= 0xdfff)) throw new ClientResultCanonicalizationError(); + } else if (c >= 0xdc00 && c <= 0xdfff) throw new ClientResultCanonicalizationError(); + } + return JSON.stringify(value); +} +/** RFC 8785 JCS for the generated closed string-only visible-state schema. */ +export function visibleStateCanonicalJson(state: ClientVisibleState): string { + const item = state.opened_item; + return '{"opened_item":' + (item === null ? 'null' : '{"body":' + quote(item.body) + ',"id":' + quote(item.id) + ',"title":' + quote(item.title) + '}') + + ',"route":' + quote(state.route) + ',"screen":' + quote(state.screen) + + ',"visible_item_ids":[' + state.visible_item_ids.map(quote).join(',') + ']}'; +} +export type ClientControlSha256 = (bytes: Uint8Array) => Promise; +export const defaultWebCryptoSha256: ClientControlSha256 = async bytes => + new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(bytes).buffer)); +export async function visibleStateDigest(state: ClientVisibleState, sha256: ClientControlSha256 = defaultWebCryptoSha256): Promise { + const digest = await sha256(new TextEncoder().encode(visibleStateCanonicalJson(state))); + return Array.from(digest, b => b.toString(16).padStart(2, '0')).join(''); +} +/** Sign using ECDSA P-256/SHA-256, IEEE P1363 r||s (64 bytes), base64url without padding. + * Never use this helper as message.send authorization. Enforce active grant first. */ +export async function clientResultCanonicalBytes(binding: ClientResultBinding, sha256: ClientControlSha256 = defaultWebCryptoSha256): Promise { + const digest = await visibleStateDigest(binding.visible_state, sha256); + const fields: [string, string | null][] = [ + ['purpose', binding.purpose], ['command_kind', binding.command_kind], ['device', binding.device], + ['session', binding.session], ['grant_id', binding.grant_id], ['run_id', binding.run_id], + ['command_id', binding.command_id], ['outcome', binding.outcome], ['error_code', binding.error_code], + ['visible_state_sha256', digest], + ]; + return new TextEncoder().encode(['smallclaims-claim-fields-v1', ...fields.map(([k, v]) => k + '=' + (v === null ? 'null' : quote(v)))].join('\n')); +} diff --git a/clients/typescript/fractal-client/Models.generated.ts b/clients/typescript/fractal-client/Models.generated.ts index 19a491ae870..7c13754ecbc 100644 --- a/clients/typescript/fractal-client/Models.generated.ts +++ b/clients/typescript/fractal-client/Models.generated.ts @@ -1265,6 +1265,21 @@ export type Capability = { version: number; }; +export type ClientCommandReceipt = { + commandId: string; + commandKind: string; + device: string; + grantId?: string; + issuer: string; + reason?: ClientControlRefusal; + runId?: string; + screen?: string; + session?: string; + signedResult: (ClientResultBinding | null); + terminalState: DeviceCommandOutcome; + verifiedResultSignature: (DeviceSignature | null); +}; + export type ClientConnection = { actor: string; client?: string | null; @@ -1286,6 +1301,34 @@ export type ClientConnections = { member: string; }; +export type ClientControlGrant = { + actor_agent: string; + device: string; + expires_at: Timestamp; + grant_id: string; + item_id?: string; + max_commands: number; + run_id: string; + screens: Array; + session: string; + state: string; + verbs: Array; +}; + +export type ClientControlGrantLifecycle = { + device: string; + grant_id: string; + item_id?: string; + kind: "client-control-grant-lifecycle"; + reason: string; + run_id: string; + sequence: number; + session: string; + state: "started" | "ended" | "revoked"; +} & ResourceHeader; + +export type ClientControlRefusal = "device-not-test-client" | "grant-missing" | "grant-expired" | "screen-not-granted" | "verb-not-granted" | "budget-exhausted" | "actor-not-granted"; + export type ClientDiagnosticBreadcrumb = "native-start" | "js-start" | "root-mounted" | "foreground" | "background" | "previous-launch-unclean"; export type ClientDiagnosticCaptureSource = "js" | "native-marker" | "metrickit"; @@ -1367,6 +1410,51 @@ export type ClientDiagnosticsBatch = { version: 1; }; +export type ClientResultBinding = { + command_id: string; + command_kind: "phone.inspect" | "phone.navigate"; + device: string; + error_code: string | null; + grant_id: string; + outcome: DeviceCommandOutcome; + purpose: "client.device-result.v1"; + run_id: string; + session: string; + visible_state: ClientVisibleState; +}; + +export type ClientTestEnrollment = { + consentRef: string; + device: string; + enrolledBy: string; + host: string; + instance: string; + kind: "ios" | "web" | "tui"; + packetDigest: string; + pairingSubject: string; + publicKey: string; + session: string; +}; + +export type ClientTestRevocation = { + device: string; + enrollmentId: string; + revokedBy: string; +}; + +export type ClientVisibleItem = { + body: string; + id: string; + title: string; +}; + +export type ClientVisibleState = { + opened_item: (ClientVisibleItem | null); + route: string; + screen: string; + visible_item_ids: Array; +}; + export type CollectionCommand = ((AgentsPublicationSubscribe & { actor?: never; agent?: never; capability?: never; conversation?: never; incarnation?: never; limit?: never; person?: never; since_ms?: never; status?: never; subject?: never; terminal?: never; until_ms?: never; }) | ({ actor?: string | null; collection: "commands"; @@ -1706,14 +1794,18 @@ export type Device = ResourceHeader & { export type DeviceCommand = { args: (PhoneInspectArgs | PhoneNavigateArgs | PhoneMessageReadArgs); command_kind: "phone.inspect" | "phone.navigate" | "phone.message.read"; + control_grant?: ClientControlGrant; device_id: string; error_code?: string; expires_at: Timestamp; + grant_id?: string; id: string; issued_at: Timestamp; issued_by: Id; kind: "device-command"; + receipt_claim_id?: string; result?: unknown; + run_id?: string; session_actor: Id; state: DeviceCommandState; } & ResourceHeader & ({ @@ -1733,18 +1825,21 @@ export type DeviceCommandRequest = ({ api_version: "st3.client.v0" | "fractal.client.v0"; args: PhoneInspectArgs; device: string; + grant_id?: string; kind: "phone.inspect"; ttl_ms?: number; } | { api_version: "st3.client.v0" | "fractal.client.v0"; args: PhoneNavigateArgs; device: string; + grant_id?: string; kind: "phone.navigate"; ttl_ms?: number; } | { api_version: "st3.client.v0" | "fractal.client.v0"; args: PhoneMessageReadArgs; device: string; + grant_id?: string; kind: "phone.message.read"; ttl_ms?: number; }); @@ -1752,10 +1847,12 @@ export type DeviceCommandRequest = ({ export type DeviceCommandState = "pending" | "delivered" | "completed" | "not-foreground" | "expired" | "refused" | "failed"; export type DeviceResult = { + binding?: ClientResultBinding; command_id: string; error_code?: string; outcome: DeviceCommandOutcome; result?: unknown; + signature?: DeviceSignature; }; export type DeviceSignature = { @@ -3319,7 +3416,7 @@ export type RequestSubject = { url?: string; }; -export type Resource = (ConnectedClient | Usage | Summary | Attention | Message | Launch | LaunchVariant | LaunchDecision | LaunchApproval | Mission | Work | Agent | Runtime | Observer | Subscription | Lane | Machine | Device | DeviceCommand | Operation | History | Session | Glass | Arrangement | OwnedSet | HarnessQuestion); +export type Resource = (ConnectedClient | Usage | Summary | Attention | Message | Launch | LaunchVariant | LaunchDecision | LaunchApproval | Mission | Work | Agent | Runtime | Observer | Subscription | Lane | Machine | Device | DeviceCommand | ClientControlGrantLifecycle | Operation | History | Session | Glass | Arrangement | OwnedSet | HarnessQuestion); export type ResourceCollectionName = "missions" diff --git a/clients/typescript/fractal-client/ReadSchema.generated.ts b/clients/typescript/fractal-client/ReadSchema.generated.ts index b6b6a73dc95..f753e78ff01 100644 --- a/clients/typescript/fractal-client/ReadSchema.generated.ts +++ b/clients/typescript/fractal-client/ReadSchema.generated.ts @@ -1668,6 +1668,40 @@ export const ClientConnections = /*#__PURE__*/ (() => Schema.Struct({ export type ClientConnections = typeof ClientConnections.Type export type ClientConnectionsEncoded = typeof ClientConnections.Encoded +export const ClientControlGrant = /*#__PURE__*/ (() => Schema.Struct({ + "actor_agent": Schema.String, + "device": Schema.String, + "expires_at": Timestamp, + "grant_id": Schema.String, + "item_id": optionalKey(Schema.String), + "max_commands": Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(Schema.isLessThanOrEqualTo(9007199254740991)), + "run_id": Schema.String, + "screens": Schema.Array(Schema.String), + "session": Schema.String, + "state": Schema.String, + "verbs": Schema.Array(Schema.String) +}).annotate({ identifier: "ClientControlGrant" }))() +export type ClientControlGrant = typeof ClientControlGrant.Type +export type ClientControlGrantEncoded = typeof ClientControlGrant.Encoded + +export const ClientControlGrantLifecycle = /*#__PURE__*/ (() => Schema.Struct({ + "device": Schema.String, + "grant_id": Schema.String, + "id": Id, + "item_id": optionalKey(Schema.String), + "kind": Schema.Literal("client-control-grant-lifecycle"), + "operational": optionalKey(Operational), + "reason": Schema.String, + "revision": Revision, + "run_id": Schema.String, + "sequence": Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(Schema.isLessThanOrEqualTo(9007199254740991)), + "session": Schema.String, + "state": Schema.Literals(["started","ended","revoked"]), + "updated_at": Timestamp +}).annotate({ identifier: "ClientControlGrantLifecycle" }))() +export type ClientControlGrantLifecycle = typeof ClientControlGrantLifecycle.Type +export type ClientControlGrantLifecycleEncoded = typeof ClientControlGrantLifecycle.Encoded + /** One conversation header field: its latest value, where it came from and when. */ export const ConversationHeaderField = /*#__PURE__*/ (() => Schema.Struct({ "as_of": Timestamp, @@ -1896,9 +1930,9 @@ export type PhoneNavigateArgs = typeof PhoneNavigateArgs.Type export type PhoneNavigateArgsEncoded = typeof PhoneNavigateArgs.Encoded export const DeviceCommand = /*#__PURE__*/ (() => Schema.Union([ - Schema.Struct({ "args": PhoneInspectArgs, "command_kind": Schema.Literal("phone.inspect"), "device_id": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "error_code": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^[A-Za-z0-9_.-]+$", "u"))).check(Schema.isMinCodePoints(1)).check(Schema.isMaxCodePoints(128))), "expires_at": Timestamp, "id": Schema.String.check(Schema.isPattern(new RegExp("^device-command/.+", "u"))), "issued_at": Timestamp, "issued_by": Id, "kind": Schema.Literal("device-command"), "operational": optionalKey(Operational), "result": optionalKey(Schema.Unknown), "revision": Revision, "session_actor": Id, "state": DeviceCommandState, "updated_at": Timestamp }), - Schema.Struct({ "args": PhoneNavigateArgs, "command_kind": Schema.Literal("phone.navigate"), "device_id": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "error_code": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^[A-Za-z0-9_.-]+$", "u"))).check(Schema.isMinCodePoints(1)).check(Schema.isMaxCodePoints(128))), "expires_at": Timestamp, "id": Schema.String.check(Schema.isPattern(new RegExp("^device-command/.+", "u"))), "issued_at": Timestamp, "issued_by": Id, "kind": Schema.Literal("device-command"), "operational": optionalKey(Operational), "result": optionalKey(Schema.Unknown), "revision": Revision, "session_actor": Id, "state": DeviceCommandState, "updated_at": Timestamp }), - Schema.Struct({ "args": PhoneMessageReadArgs, "command_kind": Schema.Literal("phone.message.read"), "device_id": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "error_code": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^[A-Za-z0-9_.-]+$", "u"))).check(Schema.isMinCodePoints(1)).check(Schema.isMaxCodePoints(128))), "expires_at": Timestamp, "id": Schema.String.check(Schema.isPattern(new RegExp("^device-command/.+", "u"))), "issued_at": Timestamp, "issued_by": Id, "kind": Schema.Literal("device-command"), "operational": optionalKey(Operational), "result": optionalKey(Schema.Unknown), "revision": Revision, "session_actor": Id, "state": DeviceCommandState, "updated_at": Timestamp }) + Schema.Struct({ "args": PhoneInspectArgs, "command_kind": Schema.Literal("phone.inspect"), "control_grant": optionalKey(ClientControlGrant), "device_id": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "error_code": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^[A-Za-z0-9_.-]+$", "u"))).check(Schema.isMinCodePoints(1)).check(Schema.isMaxCodePoints(128))), "expires_at": Timestamp, "grant_id": optionalKey(Schema.String), "id": Schema.String.check(Schema.isPattern(new RegExp("^device-command/.+", "u"))), "issued_at": Timestamp, "issued_by": Id, "kind": Schema.Literal("device-command"), "operational": optionalKey(Operational), "receipt_claim_id": optionalKey(Schema.String), "result": optionalKey(Schema.Unknown), "revision": Revision, "run_id": optionalKey(Schema.String), "session_actor": Id, "state": DeviceCommandState, "updated_at": Timestamp }), + Schema.Struct({ "args": PhoneNavigateArgs, "command_kind": Schema.Literal("phone.navigate"), "control_grant": optionalKey(ClientControlGrant), "device_id": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "error_code": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^[A-Za-z0-9_.-]+$", "u"))).check(Schema.isMinCodePoints(1)).check(Schema.isMaxCodePoints(128))), "expires_at": Timestamp, "grant_id": optionalKey(Schema.String), "id": Schema.String.check(Schema.isPattern(new RegExp("^device-command/.+", "u"))), "issued_at": Timestamp, "issued_by": Id, "kind": Schema.Literal("device-command"), "operational": optionalKey(Operational), "receipt_claim_id": optionalKey(Schema.String), "result": optionalKey(Schema.Unknown), "revision": Revision, "run_id": optionalKey(Schema.String), "session_actor": Id, "state": DeviceCommandState, "updated_at": Timestamp }), + Schema.Struct({ "args": PhoneMessageReadArgs, "command_kind": Schema.Literal("phone.message.read"), "control_grant": optionalKey(ClientControlGrant), "device_id": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "error_code": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^[A-Za-z0-9_.-]+$", "u"))).check(Schema.isMinCodePoints(1)).check(Schema.isMaxCodePoints(128))), "expires_at": Timestamp, "grant_id": optionalKey(Schema.String), "id": Schema.String.check(Schema.isPattern(new RegExp("^device-command/.+", "u"))), "issued_at": Timestamp, "issued_by": Id, "kind": Schema.Literal("device-command"), "operational": optionalKey(Operational), "receipt_claim_id": optionalKey(Schema.String), "result": optionalKey(Schema.Unknown), "revision": Revision, "run_id": optionalKey(Schema.String), "session_actor": Id, "state": DeviceCommandState, "updated_at": Timestamp }) ], { mode: "oneOf" }).pipe(Schema.toTaggedUnion("command_kind")).annotate({ identifier: "DeviceCommand" }))() export type DeviceCommand = typeof DeviceCommand.Type export type DeviceCommandEncoded = typeof DeviceCommand.Encoded @@ -2804,7 +2838,7 @@ export type SummaryEncoded = typeof Summary.Encoded export const UnknownResource = /*#__PURE__*/ (() => Schema.Struct({ "id": Id, - "kind": unknownCase(["attention","message","launch","launch-variant","launch-decision","launch-approval","mission","work","agent","runtime","observer","subscription","lane","machine","device","device-command","operation","history","session","glass","arrangement","owned-set","summary","connected-client","usage","harness-question"]), + "kind": unknownCase(["attention","message","launch","launch-variant","launch-decision","launch-approval","mission","work","agent","runtime","observer","subscription","lane","machine","device","device-command","client-control-grant-lifecycle","operation","history","session","glass","arrangement","owned-set","summary","connected-client","usage","harness-question"]), "operational": optionalKey(Operational), "revision": Revision, "updated_at": Timestamp @@ -3048,6 +3082,7 @@ export const Resource = /*#__PURE__*/ (() => Schema.Union([ Machine, Device, DeviceCommand, + ClientControlGrantLifecycle, Operation, History, Session, diff --git a/clients/typescript/fractal-client/Schema.generated.ts b/clients/typescript/fractal-client/Schema.generated.ts index bcae26143dc..4d79e62706c 100644 --- a/clients/typescript/fractal-client/Schema.generated.ts +++ b/clients/typescript/fractal-client/Schema.generated.ts @@ -10,8 +10,8 @@ import type { SubjectRef, UnknownCase } from "./SchemaSupport.generated.ts" export { API_VERSION, containsUnknownCase, decodeUnknownSync, decodeUnknownEffect } from "./SchemaSupport.generated.ts" export type { SubjectRef, UnknownCase, DecodeMode } from "./SchemaSupport.generated.ts" -import { ActorRef, Agent, AgentCheckout, AgentDeclaration, AgentDelivery, AgentEndReason, AgentHandoff, AgentId, AgentLifecycle, AgentMailCustody, AgentMessageDay, AgentMessageEstimate, AgentPresentation, AgentPresentationNow, AgentPresentationWork, AgentQueue, AgentQueueMove, AgentQueueRun, AgentReachability, AgentRelatedRun, AgentRelatedRuns, AgentRelationship, AgentRepository, AgentState, AgentStopping, AgentSubagent, AgentSuspension, AgentTodo, AgentWorkspace, AgentsChanges, AgentsPublicationMetadata, AgentsPublicationSubscribe, AgentsSnapshotChunk, AgentsStatusWatermark, Arrangement, ArrangementBody, ArrangementBodyV1, ArrangementBodyV2, ArrangementFolder, ArrangementFolderId, ArrangementId, ArrangementKey, ArrangementName, ArrangementNameRegister, ArrangementPage, ArrangementPerson, ArrangementPlacement, ArrangementPlacementRegister, ArrangementPosition, ArrangementPositionRegister, ArrangementResolved, ArrangementSubject, ArrangementTombstoneRegister, AskDefaultAnswer, AssistantAgentDetailRequest, AssistantAgentItem, AssistantAgentPage, AssistantAttentionItem, AssistantAttentionPage, AssistantContentRange, AssistantConversationContent, AssistantConversationContentRequest, AssistantConversationSearchRequest, AssistantConversationTimelineRequest, AssistantMinimalReadMode, AssistantMissionItem, AssistantMissionPage, AssistantPageRequest, AssistantPrincipal, AssistantProjection, AssistantResponseBytes, AssistantSenderClass, AssistantSummary, AssistantSummaryRequest, Attachment, Attention, AttentionActor, AttentionBlocked, AttentionBudgetDeclared, AttentionBudgetReport, AttentionBudgetRequestRef, AttentionLaunchAgent, AttentionLaunchAssignee, AttentionLaunchPreview, AttentionLaunchStep, AttentionLaunchUnavailable, AttentionLaunchUnavailableReason, AttentionPresentation, AttentionPrompt, AttentionPromptCall, AttentionRevision, AttentionRevisionLine, AttentionTargetState, AttentionWaiting, BlobChunk, CanonicalNode, Capabilities, Capability, ClientConnection, ClientConnections, CollectionName, ConnectedClient, ConversationChanges, ConversationContentChunk, ConversationContentRef, ConversationHeader, ConversationHeaderField, ConversationSearch, ConversationSearchHit, Cursor, DecisionOption, DecisionResponse, DeclaredAdmission, DeclaredAttentionBudget, DeclaredBudgets, DeclaredLane, DeclaredPriority, DeclaredResource, Device, DeviceCommand, DeviceCommandState, Diagnostic, DirectiveNote, DirectiveNotes, DocumentContent, DocumentItem, DocumentReference, DocumentReferences, DocumentsPage, ErrorCode, EventPage, ExchangeTransport, Fence, Glass, GlassBody, GlassLayout, GlassTab, HarnessInventory, HarnessInventoryItem, HarnessPhase, HarnessQuestion, HarnessQuestionAnswerability, HarnessQuestionAnswerabilityReason, HarnessQuestionItem, HarnessQuestionResolution, HarnessResolvedAnswer, HarnessTask, HarnessTodoSnapshot, HarnessTodoTotals, History, HostId, HostRepositories, Id, JobSummary, Lane, LaneChange, LaneEntry, Launch, LaunchApproval, LaunchDecision, LaunchPreview, LaunchVariant, Machine, MachineCapacity, MachineOccupancy, MachineTransport, MailBacklog, Message, MessageBody, MessageProvenance, MessageStatusFrom, Mission, MissionActionOffer, MissionDeclaration, MissionDeclarationProvenance, MissionId, MissionPresentation, MissionQueuedBehind, MissionRunId, MissionRunOutcome, MissionRunSummary, MissionState, MissionStep, MissionStepAttempt, MissionStepEta, MissionStepPresentation, MissionStepTransition, MissionStepWait, MissionStuck, MissionWake, MustAct, Observer, ObserverSpec, Operation, Operational, OrderedMembership, OrderedMembershipPage, OrderedMembershipPosition, OrderedMembershipState, OwnedSet, Page, PageInfo, PairingAdvertisement, ParticipantRef, PersonAnswer, PersonAnswerRecord, PersonId, PersonOptionNote, PersonUpdate, PhoneInspectArgs, PhoneMessageReadArgs, PhoneNavigateArgs, PhoneRouteString, PlannerConfig, PresentationActor, PrivateNotesAction, PrivateNotesData, PrivateNotesFence, PrivateNotesSubject, PrivateNotesUri, ProjectionEvent, PublicationDefinition, PushEnvironment, PushRegistrationStatus, ReadCollectionCommand, ReadCollectionFrame, RelatedRunWait, ReplicatedNotice, RequestAnswerOption, RequestSubject, Resource, ResourceCollectionName, ResourceHeader, ResourceObservation, ResourcesPage, RespondentRef, Revision, RunGenerationId, RunOutcomeStatus, Runtime, RuntimeId, ScgReadAuthorization, ScgReadAuthorizationPayload, ScheduleCoverage, ScheduleDeclaration, ScheduleKnowledge, ScheduleOwner, ScheduleUnheardHost, SchedulesPage, SenderRef, Session, SloHistory, SloList, SloRow, Snapshot, SnapshotId, StatusHistory, StatusTransition, StepRunId, StreamSync, StructuredDiff, StructuredRequest, SubagentSummary, SubjectDefinition, SubscribeTraceContext, Subscription, SubscriptionSpec, Summary, SummaryMachines, SyncNotice, SyncPeer, TimelineAskAnswer, TimelineAskQuestion, TimelineBlock, TimelineContentBody, TimelineDelta, TimelineEntry, TimelineEntryId, TimelineErrorBody, TimelineMessageBody, TimelinePage, TimelineRecipientRef, TimelineRedactionBody, TimelineStatus, TimelineStatusBody, TimelineTodoItem, TimelineToolCallBody, TimelineToolResultBody, TimelineTruncationBody, TimelineUsageBody, TimelineView, TimelineViewAsk, TimelineViewAssistantError, TimelineViewBash, TimelineViewCompaction, TimelineViewCredentialPin, TimelineViewEdit, TimelineViewEval, TimelineViewGeneric, TimelineViewHub, TimelineViewIrc, TimelineViewJob, TimelineViewModelChange, TimelineViewOmpEvent, TimelineViewRead, TimelineViewResetBoundary, TimelineViewSearch, TimelineViewSessionExit, TimelineViewSkill, TimelineViewTask, TimelineViewThinkingLevel, TimelineViewTitle, TimelineViewTodo, TimelineViewToolStart, TimelineViewWrite, Timestamp, UnknownResource, Usage, UsageAccount, UsageContext, UsageCredits, UsageLimit, UsagePeriod, UsagePricing, UsagePricingRates, UsageRow, UsageSummary, Value, Visualization, VisualizationEdge, VisualizationGroup, VisualizationNode, Work, WorkLabel, WorkState } from "./ReadSchema.generated.ts" -import type { ActorRefEncoded, AgentEncoded, AgentCheckoutEncoded, AgentDeclarationEncoded, AgentDeliveryEncoded, AgentEndReasonEncoded, AgentHandoffEncoded, AgentIdEncoded, AgentLifecycleEncoded, AgentMailCustodyEncoded, AgentMessageDayEncoded, AgentMessageEstimateEncoded, AgentPresentationEncoded, AgentPresentationNowEncoded, AgentPresentationWorkEncoded, AgentQueueEncoded, AgentQueueMoveEncoded, AgentQueueRunEncoded, AgentReachabilityEncoded, AgentRelatedRunEncoded, AgentRelatedRunsEncoded, AgentRelationshipEncoded, AgentRepositoryEncoded, AgentStateEncoded, AgentStoppingEncoded, AgentSubagentEncoded, AgentSuspensionEncoded, AgentTodoEncoded, AgentWorkspaceEncoded, AgentsChangesEncoded, AgentsPublicationMetadataEncoded, AgentsPublicationSubscribeEncoded, AgentsSnapshotChunkEncoded, AgentsStatusWatermarkEncoded, ArrangementEncoded, ArrangementBodyEncoded, ArrangementBodyV1Encoded, ArrangementBodyV2Encoded, ArrangementFolderEncoded, ArrangementFolderIdEncoded, ArrangementIdEncoded, ArrangementKeyEncoded, ArrangementNameEncoded, ArrangementNameRegisterEncoded, ArrangementPageEncoded, ArrangementPersonEncoded, ArrangementPlacementEncoded, ArrangementPlacementRegisterEncoded, ArrangementPositionEncoded, ArrangementPositionRegisterEncoded, ArrangementResolvedEncoded, ArrangementSubjectEncoded, ArrangementTombstoneRegisterEncoded, AskDefaultAnswerEncoded, AssistantAgentDetailRequestEncoded, AssistantAgentItemEncoded, AssistantAgentPageEncoded, AssistantAttentionItemEncoded, AssistantAttentionPageEncoded, AssistantContentRangeEncoded, AssistantConversationContentEncoded, AssistantConversationContentRequestEncoded, AssistantConversationSearchRequestEncoded, AssistantConversationTimelineRequestEncoded, AssistantMinimalReadModeEncoded, AssistantMissionItemEncoded, AssistantMissionPageEncoded, AssistantPageRequestEncoded, AssistantPrincipalEncoded, AssistantProjectionEncoded, AssistantResponseBytesEncoded, AssistantSenderClassEncoded, AssistantSummaryEncoded, AssistantSummaryRequestEncoded, AttachmentEncoded, AttentionEncoded, AttentionActorEncoded, AttentionBlockedEncoded, AttentionBudgetDeclaredEncoded, AttentionBudgetReportEncoded, AttentionBudgetRequestRefEncoded, AttentionLaunchAgentEncoded, AttentionLaunchAssigneeEncoded, AttentionLaunchPreviewEncoded, AttentionLaunchStepEncoded, AttentionLaunchUnavailableEncoded, AttentionLaunchUnavailableReasonEncoded, AttentionPresentationEncoded, AttentionPromptEncoded, AttentionPromptCallEncoded, AttentionRevisionEncoded, AttentionRevisionLineEncoded, AttentionTargetStateEncoded, AttentionWaitingEncoded, BlobChunkEncoded, CanonicalNodeEncoded, CapabilitiesEncoded, CapabilityEncoded, ClientConnectionEncoded, ClientConnectionsEncoded, CollectionNameEncoded, ConnectedClientEncoded, ConversationChangesEncoded, ConversationContentChunkEncoded, ConversationContentRefEncoded, ConversationHeaderEncoded, ConversationHeaderFieldEncoded, ConversationSearchEncoded, ConversationSearchHitEncoded, CursorEncoded, DecisionOptionEncoded, DecisionResponseEncoded, DeclaredAdmissionEncoded, DeclaredAttentionBudgetEncoded, DeclaredBudgetsEncoded, DeclaredLaneEncoded, DeclaredPriorityEncoded, DeclaredResourceEncoded, DeviceEncoded, DeviceCommandEncoded, DeviceCommandStateEncoded, DiagnosticEncoded, DirectiveNoteEncoded, DirectiveNotesEncoded, DocumentContentEncoded, DocumentItemEncoded, DocumentReferenceEncoded, DocumentReferencesEncoded, DocumentsPageEncoded, ErrorCodeEncoded, EventPageEncoded, ExchangeTransportEncoded, FenceEncoded, GlassEncoded, GlassBodyEncoded, GlassLayoutEncoded, GlassTabEncoded, HarnessInventoryEncoded, HarnessInventoryItemEncoded, HarnessPhaseEncoded, HarnessQuestionEncoded, HarnessQuestionAnswerabilityEncoded, HarnessQuestionAnswerabilityReasonEncoded, HarnessQuestionItemEncoded, HarnessQuestionResolutionEncoded, HarnessResolvedAnswerEncoded, HarnessTaskEncoded, HarnessTodoSnapshotEncoded, HarnessTodoTotalsEncoded, HistoryEncoded, HostIdEncoded, HostRepositoriesEncoded, IdEncoded, JobSummaryEncoded, LaneEncoded, LaneChangeEncoded, LaneEntryEncoded, LaunchEncoded, LaunchApprovalEncoded, LaunchDecisionEncoded, LaunchPreviewEncoded, LaunchVariantEncoded, MachineEncoded, MachineCapacityEncoded, MachineOccupancyEncoded, MachineTransportEncoded, MailBacklogEncoded, MessageEncoded, MessageBodyEncoded, MessageProvenanceEncoded, MessageStatusFromEncoded, MissionEncoded, MissionActionOfferEncoded, MissionDeclarationEncoded, MissionDeclarationProvenanceEncoded, MissionIdEncoded, MissionPresentationEncoded, MissionQueuedBehindEncoded, MissionRunIdEncoded, MissionRunOutcomeEncoded, MissionRunSummaryEncoded, MissionStateEncoded, MissionStepEncoded, MissionStepAttemptEncoded, MissionStepEtaEncoded, MissionStepPresentationEncoded, MissionStepTransitionEncoded, MissionStepWaitEncoded, MissionStuckEncoded, MissionWakeEncoded, MustActEncoded, ObserverEncoded, ObserverSpecEncoded, OperationEncoded, OperationalEncoded, OrderedMembershipEncoded, OrderedMembershipPageEncoded, OrderedMembershipPositionEncoded, OrderedMembershipStateEncoded, OwnedSetEncoded, PageEncoded, PageInfoEncoded, PairingAdvertisementEncoded, ParticipantRefEncoded, PersonAnswerEncoded, PersonAnswerRecordEncoded, PersonIdEncoded, PersonOptionNoteEncoded, PersonUpdateEncoded, PhoneInspectArgsEncoded, PhoneMessageReadArgsEncoded, PhoneNavigateArgsEncoded, PhoneRouteStringEncoded, PlannerConfigEncoded, PresentationActorEncoded, PrivateNotesActionEncoded, PrivateNotesDataEncoded, PrivateNotesFenceEncoded, PrivateNotesSubjectEncoded, PrivateNotesUriEncoded, ProjectionEventEncoded, PublicationDefinitionEncoded, PushEnvironmentEncoded, PushRegistrationStatusEncoded, ReadCollectionCommandEncoded, ReadCollectionFrameEncoded, RelatedRunWaitEncoded, ReplicatedNoticeEncoded, RequestAnswerOptionEncoded, RequestSubjectEncoded, ResourceEncoded, ResourceCollectionNameEncoded, ResourceHeaderEncoded, ResourceObservationEncoded, ResourcesPageEncoded, RespondentRefEncoded, RevisionEncoded, RunGenerationIdEncoded, RunOutcomeStatusEncoded, RuntimeEncoded, RuntimeIdEncoded, ScgReadAuthorizationEncoded, ScgReadAuthorizationPayloadEncoded, ScheduleCoverageEncoded, ScheduleDeclarationEncoded, ScheduleKnowledgeEncoded, ScheduleOwnerEncoded, ScheduleUnheardHostEncoded, SchedulesPageEncoded, SenderRefEncoded, SessionEncoded, SloHistoryEncoded, SloListEncoded, SloRowEncoded, SnapshotEncoded, SnapshotIdEncoded, StatusHistoryEncoded, StatusTransitionEncoded, StepRunIdEncoded, StreamSyncEncoded, StructuredDiffEncoded, StructuredRequestEncoded, SubagentSummaryEncoded, SubjectDefinitionEncoded, SubscribeTraceContextEncoded, SubscriptionEncoded, SubscriptionSpecEncoded, SummaryEncoded, SummaryMachinesEncoded, SyncNoticeEncoded, SyncPeerEncoded, TimelineAskAnswerEncoded, TimelineAskQuestionEncoded, TimelineBlockEncoded, TimelineContentBodyEncoded, TimelineDeltaEncoded, TimelineEntryEncoded, TimelineEntryIdEncoded, TimelineErrorBodyEncoded, TimelineMessageBodyEncoded, TimelinePageEncoded, TimelineRecipientRefEncoded, TimelineRedactionBodyEncoded, TimelineStatusEncoded, TimelineStatusBodyEncoded, TimelineTodoItemEncoded, TimelineToolCallBodyEncoded, TimelineToolResultBodyEncoded, TimelineTruncationBodyEncoded, TimelineUsageBodyEncoded, TimelineViewEncoded, TimelineViewAskEncoded, TimelineViewAssistantErrorEncoded, TimelineViewBashEncoded, TimelineViewCompactionEncoded, TimelineViewCredentialPinEncoded, TimelineViewEditEncoded, TimelineViewEvalEncoded, TimelineViewGenericEncoded, TimelineViewHubEncoded, TimelineViewIrcEncoded, TimelineViewJobEncoded, TimelineViewModelChangeEncoded, TimelineViewOmpEventEncoded, TimelineViewReadEncoded, TimelineViewResetBoundaryEncoded, TimelineViewSearchEncoded, TimelineViewSessionExitEncoded, TimelineViewSkillEncoded, TimelineViewTaskEncoded, TimelineViewThinkingLevelEncoded, TimelineViewTitleEncoded, TimelineViewTodoEncoded, TimelineViewToolStartEncoded, TimelineViewWriteEncoded, TimestampEncoded, UnknownResourceEncoded, UsageEncoded, UsageAccountEncoded, UsageContextEncoded, UsageCreditsEncoded, UsageLimitEncoded, UsagePeriodEncoded, UsagePricingEncoded, UsagePricingRatesEncoded, UsageRowEncoded, UsageSummaryEncoded, ValueEncoded, VisualizationEncoded, VisualizationEdgeEncoded, VisualizationGroupEncoded, VisualizationNodeEncoded, WorkEncoded, WorkLabelEncoded, WorkStateEncoded } from "./ReadSchema.generated.ts" +import { ActorRef, Agent, AgentCheckout, AgentDeclaration, AgentDelivery, AgentEndReason, AgentHandoff, AgentId, AgentLifecycle, AgentMailCustody, AgentMessageDay, AgentMessageEstimate, AgentPresentation, AgentPresentationNow, AgentPresentationWork, AgentQueue, AgentQueueMove, AgentQueueRun, AgentReachability, AgentRelatedRun, AgentRelatedRuns, AgentRelationship, AgentRepository, AgentState, AgentStopping, AgentSubagent, AgentSuspension, AgentTodo, AgentWorkspace, AgentsChanges, AgentsPublicationMetadata, AgentsPublicationSubscribe, AgentsSnapshotChunk, AgentsStatusWatermark, Arrangement, ArrangementBody, ArrangementBodyV1, ArrangementBodyV2, ArrangementFolder, ArrangementFolderId, ArrangementId, ArrangementKey, ArrangementName, ArrangementNameRegister, ArrangementPage, ArrangementPerson, ArrangementPlacement, ArrangementPlacementRegister, ArrangementPosition, ArrangementPositionRegister, ArrangementResolved, ArrangementSubject, ArrangementTombstoneRegister, AskDefaultAnswer, AssistantAgentDetailRequest, AssistantAgentItem, AssistantAgentPage, AssistantAttentionItem, AssistantAttentionPage, AssistantContentRange, AssistantConversationContent, AssistantConversationContentRequest, AssistantConversationSearchRequest, AssistantConversationTimelineRequest, AssistantMinimalReadMode, AssistantMissionItem, AssistantMissionPage, AssistantPageRequest, AssistantPrincipal, AssistantProjection, AssistantResponseBytes, AssistantSenderClass, AssistantSummary, AssistantSummaryRequest, Attachment, Attention, AttentionActor, AttentionBlocked, AttentionBudgetDeclared, AttentionBudgetReport, AttentionBudgetRequestRef, AttentionLaunchAgent, AttentionLaunchAssignee, AttentionLaunchPreview, AttentionLaunchStep, AttentionLaunchUnavailable, AttentionLaunchUnavailableReason, AttentionPresentation, AttentionPrompt, AttentionPromptCall, AttentionRevision, AttentionRevisionLine, AttentionTargetState, AttentionWaiting, BlobChunk, CanonicalNode, Capabilities, Capability, ClientConnection, ClientConnections, ClientControlGrant, ClientControlGrantLifecycle, CollectionName, ConnectedClient, ConversationChanges, ConversationContentChunk, ConversationContentRef, ConversationHeader, ConversationHeaderField, ConversationSearch, ConversationSearchHit, Cursor, DecisionOption, DecisionResponse, DeclaredAdmission, DeclaredAttentionBudget, DeclaredBudgets, DeclaredLane, DeclaredPriority, DeclaredResource, Device, DeviceCommand, DeviceCommandState, Diagnostic, DirectiveNote, DirectiveNotes, DocumentContent, DocumentItem, DocumentReference, DocumentReferences, DocumentsPage, ErrorCode, EventPage, ExchangeTransport, Fence, Glass, GlassBody, GlassLayout, GlassTab, HarnessInventory, HarnessInventoryItem, HarnessPhase, HarnessQuestion, HarnessQuestionAnswerability, HarnessQuestionAnswerabilityReason, HarnessQuestionItem, HarnessQuestionResolution, HarnessResolvedAnswer, HarnessTask, HarnessTodoSnapshot, HarnessTodoTotals, History, HostId, HostRepositories, Id, JobSummary, Lane, LaneChange, LaneEntry, Launch, LaunchApproval, LaunchDecision, LaunchPreview, LaunchVariant, Machine, MachineCapacity, MachineOccupancy, MachineTransport, MailBacklog, Message, MessageBody, MessageProvenance, MessageStatusFrom, Mission, MissionActionOffer, MissionDeclaration, MissionDeclarationProvenance, MissionId, MissionPresentation, MissionQueuedBehind, MissionRunId, MissionRunOutcome, MissionRunSummary, MissionState, MissionStep, MissionStepAttempt, MissionStepEta, MissionStepPresentation, MissionStepTransition, MissionStepWait, MissionStuck, MissionWake, MustAct, Observer, ObserverSpec, Operation, Operational, OrderedMembership, OrderedMembershipPage, OrderedMembershipPosition, OrderedMembershipState, OwnedSet, Page, PageInfo, PairingAdvertisement, ParticipantRef, PersonAnswer, PersonAnswerRecord, PersonId, PersonOptionNote, PersonUpdate, PhoneInspectArgs, PhoneMessageReadArgs, PhoneNavigateArgs, PhoneRouteString, PlannerConfig, PresentationActor, PrivateNotesAction, PrivateNotesData, PrivateNotesFence, PrivateNotesSubject, PrivateNotesUri, ProjectionEvent, PublicationDefinition, PushEnvironment, PushRegistrationStatus, ReadCollectionCommand, ReadCollectionFrame, RelatedRunWait, ReplicatedNotice, RequestAnswerOption, RequestSubject, Resource, ResourceCollectionName, ResourceHeader, ResourceObservation, ResourcesPage, RespondentRef, Revision, RunGenerationId, RunOutcomeStatus, Runtime, RuntimeId, ScgReadAuthorization, ScgReadAuthorizationPayload, ScheduleCoverage, ScheduleDeclaration, ScheduleKnowledge, ScheduleOwner, ScheduleUnheardHost, SchedulesPage, SenderRef, Session, SloHistory, SloList, SloRow, Snapshot, SnapshotId, StatusHistory, StatusTransition, StepRunId, StreamSync, StructuredDiff, StructuredRequest, SubagentSummary, SubjectDefinition, SubscribeTraceContext, Subscription, SubscriptionSpec, Summary, SummaryMachines, SyncNotice, SyncPeer, TimelineAskAnswer, TimelineAskQuestion, TimelineBlock, TimelineContentBody, TimelineDelta, TimelineEntry, TimelineEntryId, TimelineErrorBody, TimelineMessageBody, TimelinePage, TimelineRecipientRef, TimelineRedactionBody, TimelineStatus, TimelineStatusBody, TimelineTodoItem, TimelineToolCallBody, TimelineToolResultBody, TimelineTruncationBody, TimelineUsageBody, TimelineView, TimelineViewAsk, TimelineViewAssistantError, TimelineViewBash, TimelineViewCompaction, TimelineViewCredentialPin, TimelineViewEdit, TimelineViewEval, TimelineViewGeneric, TimelineViewHub, TimelineViewIrc, TimelineViewJob, TimelineViewModelChange, TimelineViewOmpEvent, TimelineViewRead, TimelineViewResetBoundary, TimelineViewSearch, TimelineViewSessionExit, TimelineViewSkill, TimelineViewTask, TimelineViewThinkingLevel, TimelineViewTitle, TimelineViewTodo, TimelineViewToolStart, TimelineViewWrite, Timestamp, UnknownResource, Usage, UsageAccount, UsageContext, UsageCredits, UsageLimit, UsagePeriod, UsagePricing, UsagePricingRates, UsageRow, UsageSummary, Value, Visualization, VisualizationEdge, VisualizationGroup, VisualizationNode, Work, WorkLabel, WorkState } from "./ReadSchema.generated.ts" +import type { ActorRefEncoded, AgentEncoded, AgentCheckoutEncoded, AgentDeclarationEncoded, AgentDeliveryEncoded, AgentEndReasonEncoded, AgentHandoffEncoded, AgentIdEncoded, AgentLifecycleEncoded, AgentMailCustodyEncoded, AgentMessageDayEncoded, AgentMessageEstimateEncoded, AgentPresentationEncoded, AgentPresentationNowEncoded, AgentPresentationWorkEncoded, AgentQueueEncoded, AgentQueueMoveEncoded, AgentQueueRunEncoded, AgentReachabilityEncoded, AgentRelatedRunEncoded, AgentRelatedRunsEncoded, AgentRelationshipEncoded, AgentRepositoryEncoded, AgentStateEncoded, AgentStoppingEncoded, AgentSubagentEncoded, AgentSuspensionEncoded, AgentTodoEncoded, AgentWorkspaceEncoded, AgentsChangesEncoded, AgentsPublicationMetadataEncoded, AgentsPublicationSubscribeEncoded, AgentsSnapshotChunkEncoded, AgentsStatusWatermarkEncoded, ArrangementEncoded, ArrangementBodyEncoded, ArrangementBodyV1Encoded, ArrangementBodyV2Encoded, ArrangementFolderEncoded, ArrangementFolderIdEncoded, ArrangementIdEncoded, ArrangementKeyEncoded, ArrangementNameEncoded, ArrangementNameRegisterEncoded, ArrangementPageEncoded, ArrangementPersonEncoded, ArrangementPlacementEncoded, ArrangementPlacementRegisterEncoded, ArrangementPositionEncoded, ArrangementPositionRegisterEncoded, ArrangementResolvedEncoded, ArrangementSubjectEncoded, ArrangementTombstoneRegisterEncoded, AskDefaultAnswerEncoded, AssistantAgentDetailRequestEncoded, AssistantAgentItemEncoded, AssistantAgentPageEncoded, AssistantAttentionItemEncoded, AssistantAttentionPageEncoded, AssistantContentRangeEncoded, AssistantConversationContentEncoded, AssistantConversationContentRequestEncoded, AssistantConversationSearchRequestEncoded, AssistantConversationTimelineRequestEncoded, AssistantMinimalReadModeEncoded, AssistantMissionItemEncoded, AssistantMissionPageEncoded, AssistantPageRequestEncoded, AssistantPrincipalEncoded, AssistantProjectionEncoded, AssistantResponseBytesEncoded, AssistantSenderClassEncoded, AssistantSummaryEncoded, AssistantSummaryRequestEncoded, AttachmentEncoded, AttentionEncoded, AttentionActorEncoded, AttentionBlockedEncoded, AttentionBudgetDeclaredEncoded, AttentionBudgetReportEncoded, AttentionBudgetRequestRefEncoded, AttentionLaunchAgentEncoded, AttentionLaunchAssigneeEncoded, AttentionLaunchPreviewEncoded, AttentionLaunchStepEncoded, AttentionLaunchUnavailableEncoded, AttentionLaunchUnavailableReasonEncoded, AttentionPresentationEncoded, AttentionPromptEncoded, AttentionPromptCallEncoded, AttentionRevisionEncoded, AttentionRevisionLineEncoded, AttentionTargetStateEncoded, AttentionWaitingEncoded, BlobChunkEncoded, CanonicalNodeEncoded, CapabilitiesEncoded, CapabilityEncoded, ClientConnectionEncoded, ClientConnectionsEncoded, ClientControlGrantEncoded, ClientControlGrantLifecycleEncoded, CollectionNameEncoded, ConnectedClientEncoded, ConversationChangesEncoded, ConversationContentChunkEncoded, ConversationContentRefEncoded, ConversationHeaderEncoded, ConversationHeaderFieldEncoded, ConversationSearchEncoded, ConversationSearchHitEncoded, CursorEncoded, DecisionOptionEncoded, DecisionResponseEncoded, DeclaredAdmissionEncoded, DeclaredAttentionBudgetEncoded, DeclaredBudgetsEncoded, DeclaredLaneEncoded, DeclaredPriorityEncoded, DeclaredResourceEncoded, DeviceEncoded, DeviceCommandEncoded, DeviceCommandStateEncoded, DiagnosticEncoded, DirectiveNoteEncoded, DirectiveNotesEncoded, DocumentContentEncoded, DocumentItemEncoded, DocumentReferenceEncoded, DocumentReferencesEncoded, DocumentsPageEncoded, ErrorCodeEncoded, EventPageEncoded, ExchangeTransportEncoded, FenceEncoded, GlassEncoded, GlassBodyEncoded, GlassLayoutEncoded, GlassTabEncoded, HarnessInventoryEncoded, HarnessInventoryItemEncoded, HarnessPhaseEncoded, HarnessQuestionEncoded, HarnessQuestionAnswerabilityEncoded, HarnessQuestionAnswerabilityReasonEncoded, HarnessQuestionItemEncoded, HarnessQuestionResolutionEncoded, HarnessResolvedAnswerEncoded, HarnessTaskEncoded, HarnessTodoSnapshotEncoded, HarnessTodoTotalsEncoded, HistoryEncoded, HostIdEncoded, HostRepositoriesEncoded, IdEncoded, JobSummaryEncoded, LaneEncoded, LaneChangeEncoded, LaneEntryEncoded, LaunchEncoded, LaunchApprovalEncoded, LaunchDecisionEncoded, LaunchPreviewEncoded, LaunchVariantEncoded, MachineEncoded, MachineCapacityEncoded, MachineOccupancyEncoded, MachineTransportEncoded, MailBacklogEncoded, MessageEncoded, MessageBodyEncoded, MessageProvenanceEncoded, MessageStatusFromEncoded, MissionEncoded, MissionActionOfferEncoded, MissionDeclarationEncoded, MissionDeclarationProvenanceEncoded, MissionIdEncoded, MissionPresentationEncoded, MissionQueuedBehindEncoded, MissionRunIdEncoded, MissionRunOutcomeEncoded, MissionRunSummaryEncoded, MissionStateEncoded, MissionStepEncoded, MissionStepAttemptEncoded, MissionStepEtaEncoded, MissionStepPresentationEncoded, MissionStepTransitionEncoded, MissionStepWaitEncoded, MissionStuckEncoded, MissionWakeEncoded, MustActEncoded, ObserverEncoded, ObserverSpecEncoded, OperationEncoded, OperationalEncoded, OrderedMembershipEncoded, OrderedMembershipPageEncoded, OrderedMembershipPositionEncoded, OrderedMembershipStateEncoded, OwnedSetEncoded, PageEncoded, PageInfoEncoded, PairingAdvertisementEncoded, ParticipantRefEncoded, PersonAnswerEncoded, PersonAnswerRecordEncoded, PersonIdEncoded, PersonOptionNoteEncoded, PersonUpdateEncoded, PhoneInspectArgsEncoded, PhoneMessageReadArgsEncoded, PhoneNavigateArgsEncoded, PhoneRouteStringEncoded, PlannerConfigEncoded, PresentationActorEncoded, PrivateNotesActionEncoded, PrivateNotesDataEncoded, PrivateNotesFenceEncoded, PrivateNotesSubjectEncoded, PrivateNotesUriEncoded, ProjectionEventEncoded, PublicationDefinitionEncoded, PushEnvironmentEncoded, PushRegistrationStatusEncoded, ReadCollectionCommandEncoded, ReadCollectionFrameEncoded, RelatedRunWaitEncoded, ReplicatedNoticeEncoded, RequestAnswerOptionEncoded, RequestSubjectEncoded, ResourceEncoded, ResourceCollectionNameEncoded, ResourceHeaderEncoded, ResourceObservationEncoded, ResourcesPageEncoded, RespondentRefEncoded, RevisionEncoded, RunGenerationIdEncoded, RunOutcomeStatusEncoded, RuntimeEncoded, RuntimeIdEncoded, ScgReadAuthorizationEncoded, ScgReadAuthorizationPayloadEncoded, ScheduleCoverageEncoded, ScheduleDeclarationEncoded, ScheduleKnowledgeEncoded, ScheduleOwnerEncoded, ScheduleUnheardHostEncoded, SchedulesPageEncoded, SenderRefEncoded, SessionEncoded, SloHistoryEncoded, SloListEncoded, SloRowEncoded, SnapshotEncoded, SnapshotIdEncoded, StatusHistoryEncoded, StatusTransitionEncoded, StepRunIdEncoded, StreamSyncEncoded, StructuredDiffEncoded, StructuredRequestEncoded, SubagentSummaryEncoded, SubjectDefinitionEncoded, SubscribeTraceContextEncoded, SubscriptionEncoded, SubscriptionSpecEncoded, SummaryEncoded, SummaryMachinesEncoded, SyncNoticeEncoded, SyncPeerEncoded, TimelineAskAnswerEncoded, TimelineAskQuestionEncoded, TimelineBlockEncoded, TimelineContentBodyEncoded, TimelineDeltaEncoded, TimelineEntryEncoded, TimelineEntryIdEncoded, TimelineErrorBodyEncoded, TimelineMessageBodyEncoded, TimelinePageEncoded, TimelineRecipientRefEncoded, TimelineRedactionBodyEncoded, TimelineStatusEncoded, TimelineStatusBodyEncoded, TimelineTodoItemEncoded, TimelineToolCallBodyEncoded, TimelineToolResultBodyEncoded, TimelineTruncationBodyEncoded, TimelineUsageBodyEncoded, TimelineViewEncoded, TimelineViewAskEncoded, TimelineViewAssistantErrorEncoded, TimelineViewBashEncoded, TimelineViewCompactionEncoded, TimelineViewCredentialPinEncoded, TimelineViewEditEncoded, TimelineViewEvalEncoded, TimelineViewGenericEncoded, TimelineViewHubEncoded, TimelineViewIrcEncoded, TimelineViewJobEncoded, TimelineViewModelChangeEncoded, TimelineViewOmpEventEncoded, TimelineViewReadEncoded, TimelineViewResetBoundaryEncoded, TimelineViewSearchEncoded, TimelineViewSessionExitEncoded, TimelineViewSkillEncoded, TimelineViewTaskEncoded, TimelineViewThinkingLevelEncoded, TimelineViewTitleEncoded, TimelineViewTodoEncoded, TimelineViewToolStartEncoded, TimelineViewWriteEncoded, TimestampEncoded, UnknownResourceEncoded, UsageEncoded, UsageAccountEncoded, UsageContextEncoded, UsageCreditsEncoded, UsageLimitEncoded, UsagePeriodEncoded, UsagePricingEncoded, UsagePricingRatesEncoded, UsageRowEncoded, UsageSummaryEncoded, ValueEncoded, VisualizationEncoded, VisualizationEdgeEncoded, VisualizationGroupEncoded, VisualizationNodeEncoded, WorkEncoded, WorkLabelEncoded, WorkStateEncoded } from "./ReadSchema.generated.ts" export * from "./ReadSchema.generated.ts" export const ActionCommon = /*#__PURE__*/ (() => Schema.Struct({ @@ -585,6 +585,63 @@ export const BlobUpload = /*#__PURE__*/ (() => Schema.Struct({ export type BlobUpload = typeof BlobUpload.Type export type BlobUploadEncoded = typeof BlobUpload.Encoded +export const ClientControlRefusal = /*#__PURE__*/ (() => Schema.Literals(["device-not-test-client","grant-missing","grant-expired","screen-not-granted","verb-not-granted","budget-exhausted","actor-not-granted"]).annotate({ identifier: "ClientControlRefusal" }))() +export type ClientControlRefusal = typeof ClientControlRefusal.Type +export type ClientControlRefusalEncoded = typeof ClientControlRefusal.Encoded + +export const ClientVisibleItem = /*#__PURE__*/ (() => Schema.Struct({ + "body": Schema.String, + "id": Schema.String, + "title": Schema.String +}).annotate({ identifier: "ClientVisibleItem" }))() +export type ClientVisibleItem = typeof ClientVisibleItem.Type +export type ClientVisibleItemEncoded = typeof ClientVisibleItem.Encoded + +export const ClientVisibleState = /*#__PURE__*/ (() => Schema.Struct({ + "opened_item": Schema.OptionFromNullOr(ClientVisibleItem), + "route": Schema.String, + "screen": Schema.String, + "visible_item_ids": Schema.Array(Schema.String) +}).annotate({ identifier: "ClientVisibleState" }))() +export type ClientVisibleState = typeof ClientVisibleState.Type +export type ClientVisibleStateEncoded = typeof ClientVisibleState.Encoded + +export const DeviceCommandOutcome = /*#__PURE__*/ (() => Schema.Literals(["completed","not-foreground","expired","refused","failed"]).annotate({ identifier: "DeviceCommandOutcome" }))() +export type DeviceCommandOutcome = typeof DeviceCommandOutcome.Type +export type DeviceCommandOutcomeEncoded = typeof DeviceCommandOutcome.Encoded + +export const ClientResultBinding = /*#__PURE__*/ (() => Schema.Struct({ + "command_id": Schema.String, + "command_kind": Schema.Literals(["phone.inspect","phone.navigate"]), + "device": Schema.String, + "error_code": Schema.OptionFromNullOr(Schema.String), + "grant_id": Schema.String, + "outcome": DeviceCommandOutcome, + "purpose": Schema.Literal("client.device-result.v1"), + "run_id": Schema.String, + "session": Schema.String, + "visible_state": ClientVisibleState +}).annotate({ identifier: "ClientResultBinding" }))() +export type ClientResultBinding = typeof ClientResultBinding.Type +export type ClientResultBindingEncoded = typeof ClientResultBinding.Encoded + +export const ClientCommandReceipt = /*#__PURE__*/ (() => Schema.Struct({ + "commandId": Schema.String, + "commandKind": Schema.String, + "device": Schema.String, + "grantId": optionalKey(Schema.String), + "issuer": Schema.String, + "reason": optionalKey(ClientControlRefusal), + "runId": optionalKey(Schema.String), + "screen": optionalKey(Schema.String), + "session": optionalKey(Schema.String), + "signedResult": Schema.OptionFromNullOr(ClientResultBinding), + "terminalState": DeviceCommandOutcome, + "verifiedResultSignature": Schema.OptionFromNullOr(DeviceSignature) +}).annotate({ identifier: "ClientCommandReceipt" }))() +export type ClientCommandReceipt = typeof ClientCommandReceipt.Type +export type ClientCommandReceiptEncoded = typeof ClientCommandReceipt.Encoded + export const ClientDiagnosticBreadcrumb = /*#__PURE__*/ (() => Schema.Literals(["native-start","js-start","root-mounted","foreground","background","previous-launch-unclean"]).annotate({ identifier: "ClientDiagnosticBreadcrumb" }))() export type ClientDiagnosticBreadcrumb = typeof ClientDiagnosticBreadcrumb.Type export type ClientDiagnosticBreadcrumbEncoded = typeof ClientDiagnosticBreadcrumb.Encoded @@ -693,6 +750,29 @@ export const ClientDiagnosticsBatch = /*#__PURE__*/ (() => Schema.Struct({ export type ClientDiagnosticsBatch = typeof ClientDiagnosticsBatch.Type export type ClientDiagnosticsBatchEncoded = typeof ClientDiagnosticsBatch.Encoded +export const ClientTestEnrollment = /*#__PURE__*/ (() => Schema.Struct({ + "consentRef": Schema.String, + "device": Schema.String, + "enrolledBy": Schema.String, + "host": Schema.String, + "instance": Schema.String, + "kind": Schema.Literals(["ios","web","tui"]), + "packetDigest": Schema.String, + "pairingSubject": Schema.String, + "publicKey": Schema.String, + "session": Schema.String +}).annotate({ identifier: "ClientTestEnrollment" }))() +export type ClientTestEnrollment = typeof ClientTestEnrollment.Type +export type ClientTestEnrollmentEncoded = typeof ClientTestEnrollment.Encoded + +export const ClientTestRevocation = /*#__PURE__*/ (() => Schema.Struct({ + "device": Schema.String, + "enrollmentId": Schema.String, + "revokedBy": Schema.String +}).annotate({ identifier: "ClientTestRevocation" }))() +export type ClientTestRevocation = typeof ClientTestRevocation.Type +export type ClientTestRevocationEncoded = typeof ClientTestRevocation.Encoded + export const CollectionCommand = /*#__PURE__*/ (() => Schema.Union([ Schema.Struct({ "agents_publication_version": Schema.Literal(1), "collection": Schema.Literal("agents"), "id": Schema.String.check(Schema.isMinCodePoints(1)).check(Schema.isMaxCodePoints(128)), "kind": Schema.Literal("subscribe"), "trace": optionalKey(SubscribeTraceContext), "actor": Schema.optionalKey(Schema.Never), "agent": Schema.optionalKey(Schema.Never), "capability": Schema.optionalKey(Schema.Never), "conversation": Schema.optionalKey(Schema.Never), "incarnation": Schema.optionalKey(Schema.Never), "limit": Schema.optionalKey(Schema.Never), "person": Schema.optionalKey(Schema.Never), "since_ms": Schema.optionalKey(Schema.Never), "status": Schema.optionalKey(Schema.Never), "subject": Schema.optionalKey(Schema.Never), "terminal": Schema.optionalKey(Schema.Never), "until_ms": Schema.optionalKey(Schema.Never) }), Schema.Struct({ "actor": Schema.OptionFromOptionalNullOr(Schema.String, NULL_NONE), "collection": Schema.Literal("commands"), "id": Schema.String.check(Schema.isMinCodePoints(1)).check(Schema.isMaxCodePoints(128)), "kind": Schema.Literal("subscribe"), "limit": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(Schema.isLessThanOrEqualTo(200))), "status": Schema.OptionFromOptionalNullOr(Schema.String, NULL_NONE), "trace": optionalKey(SubscribeTraceContext), "agent": Schema.optionalKey(Schema.Never), "agents_publication_version": Schema.optionalKey(Schema.Never), "capability": Schema.optionalKey(Schema.Never), "conversation": Schema.optionalKey(Schema.Never), "incarnation": Schema.optionalKey(Schema.Never), "person": Schema.optionalKey(Schema.Never), "since_ms": Schema.optionalKey(Schema.Never), "subject": Schema.optionalKey(Schema.Never), "terminal": Schema.optionalKey(Schema.Never), "until_ms": Schema.optionalKey(Schema.Never) }), @@ -834,25 +914,23 @@ export const CollectionFrame = /*#__PURE__*/ (() => Schema.Union([ export type CollectionFrame = typeof CollectionFrame.Type export type CollectionFrameEncoded = typeof CollectionFrame.Encoded -export const DeviceCommandOutcome = /*#__PURE__*/ (() => Schema.Literals(["completed","not-foreground","expired","refused","failed"]).annotate({ identifier: "DeviceCommandOutcome" }))() -export type DeviceCommandOutcome = typeof DeviceCommandOutcome.Type -export type DeviceCommandOutcomeEncoded = typeof DeviceCommandOutcome.Encoded - /** One expiring foreground command. Default TTL is 30000 ms, maximum 60000 ms; at most eight pending commands per device. */ export const DeviceCommandRequest = /*#__PURE__*/ (() => Schema.Union([ - Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "args": PhoneInspectArgs, "device": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "kind": Schema.Literal("phone.inspect"), "ttl_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(Schema.isLessThanOrEqualTo(60000))) }), - Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "args": PhoneNavigateArgs, "device": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "kind": Schema.Literal("phone.navigate"), "ttl_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(Schema.isLessThanOrEqualTo(60000))) }), - Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "args": PhoneMessageReadArgs, "device": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "kind": Schema.Literal("phone.message.read"), "ttl_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(Schema.isLessThanOrEqualTo(60000))) }) + Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "args": PhoneInspectArgs, "device": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "grant_id": optionalKey(Schema.String), "kind": Schema.Literal("phone.inspect"), "ttl_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(Schema.isLessThanOrEqualTo(60000))) }), + Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "args": PhoneNavigateArgs, "device": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "grant_id": optionalKey(Schema.String), "kind": Schema.Literal("phone.navigate"), "ttl_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(Schema.isLessThanOrEqualTo(60000))) }), + Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "args": PhoneMessageReadArgs, "device": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "grant_id": optionalKey(Schema.String), "kind": Schema.Literal("phone.message.read"), "ttl_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(Schema.isLessThanOrEqualTo(60000))) }) ], { mode: "oneOf" }).pipe(Schema.toTaggedUnion("kind")).annotate({ identifier: "DeviceCommandRequest", description: "One expiring foreground command. Default TTL is 30000 ms, maximum 60000 ms; at most eight pending commands per device." }))() export type DeviceCommandRequest = typeof DeviceCommandRequest.Type export type DeviceCommandRequestEncoded = typeof DeviceCommandRequest.Encoded /** Terminal phone report; authenticated pairing supplies the device identity. The daemon clock adjudicates expiry. */ export const DeviceResult = /*#__PURE__*/ (() => Schema.Struct({ + "binding": optionalKey(ClientResultBinding), "command_id": Schema.String.check(Schema.isPattern(new RegExp("^device-command/.+", "u"))), "error_code": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^[A-Za-z0-9_.-]+$", "u"))).check(Schema.isMinCodePoints(1)).check(Schema.isMaxCodePoints(128))), "outcome": DeviceCommandOutcome, - "result": optionalKey(Schema.Unknown) + "result": optionalKey(Schema.Unknown), + "signature": optionalKey(DeviceSignature) }).annotate({ identifier: "DeviceResult", description: "Terminal phone report; authenticated pairing supplies the device identity. The daemon clock adjudicates expiry." }))() export type DeviceResult = typeof DeviceResult.Type export type DeviceResultEncoded = typeof DeviceResult.Encoded diff --git a/clients/typescript/fractal-client/client-control.test.cjs b/clients/typescript/fractal-client/client-control.test.cjs new file mode 100644 index 00000000000..b59a1011d79 --- /dev/null +++ b/clients/typescript/fractal-client/client-control.test.cjs @@ -0,0 +1,57 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); +const { webcrypto, createPublicKey, createHash, verify } = require('node:crypto'); +const Module = require('node:module'); +const filename = path.join(__dirname, 'ClientControl.generated.ts'); +const source = ts.transpileModule(fs.readFileSync(filename, 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } +}).outputText; +const loaded = new Module(filename, module); +loaded.filename = filename; +loaded.paths = module.paths; +loaded._compile(source, filename); +const { ClientResultCanonicalizationError, visibleStateCanonicalJson, visibleStateDigest, clientResultCanonicalBytes } = loaded.exports; +const fixture = JSON.parse(fs.readFileSync(path.join(__dirname, '../../../fixtures/clients/client-control-v1.golden.json'), 'utf8')); +if (!globalThis.crypto) globalThis.crypto = webcrypto; +const point = Buffer.from(fixture.public_key_sec1_base64url, 'base64url'); +const key = createPublicKey({ key: { kty: 'EC', crv: 'P-256', x: point.subarray(1, 33).toString('base64url'), y: point.subarray(33).toString('base64url') }, format: 'jwk' }); +function verifies(bytes, signature) { + return verify('sha256', bytes, { key, dsaEncoding: 'ieee-p1363' }, signature); +} +test('all clients share canonical bytes, digest and purpose-specific fixed-key signatures', async () => { + const message = Buffer.from(fixture.message_domain_bytes); + const messageSignature = Buffer.from(fixture.message_domain_signature_p1363_base64url, 'base64url'); + assert.ok(verifies(message, messageSignature)); + for (const vector of fixture.vectors) { + assert.equal(visibleStateCanonicalJson(vector.input.visible_state), vector.canonical_visible_state); + assert.equal(await visibleStateDigest(vector.input.visible_state), vector.visible_state_sha256); + const bytes = await clientResultCanonicalBytes(vector.input); + assert.equal(Buffer.from(bytes).toString('utf8'), vector.canonical_bytes); + const signature = Buffer.from(vector.signature_p1363_base64url, 'base64url'); + assert.ok(verifies(bytes, signature)); + for (const changed of vector.negative_bindings) assert.equal(verifies(await clientResultCanonicalBytes(changed), signature), false); + assert.equal(verifies(message, signature), false); + assert.equal(verifies(bytes, messageSignature), false); + } +}); +test('JCS exposes typed lone-surrogate refusals through canonical byte generation', async () => { + for (const route of ['\ud800', '\udc00']) { + const state = { ...fixture.vectors[0].input.visible_state, route }; + const isInvalidUnicode = error => error instanceof ClientResultCanonicalizationError && error.code === 'invalid-unicode'; + assert.throws(() => visibleStateCanonicalJson(state), isInvalidUnicode); + await assert.rejects(clientResultCanonicalBytes({ ...fixture.vectors[0].input, visible_state: state }), isInvalidUnicode); + } +}); + +test('injected SHA256 reproduces golden bytes without a WebCrypto dependency', async () => { + let calls = 0; + const sha256 = async bytes => { calls++; return new Uint8Array(createHash('sha256').update(bytes).digest()); }; + for (const vector of fixture.vectors) { + assert.equal(await visibleStateDigest(vector.input.visible_state, sha256), vector.visible_state_sha256); + assert.equal(Buffer.from(await clientResultCanonicalBytes(vector.input, sha256)).toString('utf8'), vector.canonical_bytes); + } + assert.equal(calls, fixture.vectors.length * 2); +}); diff --git a/clients/typescript/fractal-client/index.ts b/clients/typescript/fractal-client/index.ts index 896488ca4e6..afc4c587448 100644 --- a/clients/typescript/fractal-client/index.ts +++ b/clients/typescript/fractal-client/index.ts @@ -2,3 +2,5 @@ export * from './Models.generated.ts'; export * from './Client.generated.ts'; export * from './errors.ts'; export * from './subagent-drivers.ts'; +export { CLIENT_RESULT_PURPOSE, CLIENT_RESULT_SIGNED_FIELDS, ClientResultCanonicalizationError, defaultWebCryptoSha256, visibleStateCanonicalJson, visibleStateDigest, clientResultCanonicalBytes } from './ClientControl.generated.ts'; +export type { ClientControlSha256 } from './ClientControl.generated.ts'; diff --git a/clients/typescript/fractal-client/package.json b/clients/typescript/fractal-client/package.json index c7ec3e82f32..c5895ed3e32 100644 --- a/clients/typescript/fractal-client/package.json +++ b/clients/typescript/fractal-client/package.json @@ -5,8 +5,8 @@ "license": "MIT", "type": "module", "scripts": { - "test": "node --test contract.test.cjs schema.test.cjs fabric.test.cjs", - "test:contract": "node --test contract.test.cjs", + "test": "node --test contract.test.cjs schema.test.cjs fabric.test.cjs client-control.test.cjs", + "test:contract": "node --test contract.test.cjs client-control.test.cjs", "test:schema": "node --test schema.test.cjs", "typecheck": "pnpm run typecheck:client && pnpm run typecheck:schema", "typecheck:client": "tsc -p tsconfig.json", diff --git a/clients/typescript/fractal-client/schema.test.cjs b/clients/typescript/fractal-client/schema.test.cjs index b87ca5bc567..fde040b6049 100644 --- a/clients/typescript/fractal-client/schema.test.cjs +++ b/clients/typescript/fractal-client/schema.test.cjs @@ -1335,3 +1335,35 @@ test('notice fixtures preserve rich Options, identifiers and lossless timestamps // Existing timestamp annotations retain the established DateTime.Utc contract. assert.equal(DateTime.toEpochMillis(Rich.decodeUnknownSync(Rich.Timestamp)(wire)), Date.parse(wire)); }); + +test('commands Resource and CollectionFrame decode shared lifecycle vectors', async () => { + const [, Rich] = await modules; + const fs = require('node:fs'); + const path = require('node:path'); + const fixture = JSON.parse(fs.readFileSync(path.join(__dirname, '../../../fixtures/clients/client-control-lifecycle-v1.json'), 'utf8')); + for (const row of fixture.resources) { + const decoded = Rich.decodeUnknownSync(Rich.Resource, 'strict')(row); + assert.equal(decoded.kind, 'client-control-grant-lifecycle'); + assert.equal(decoded.grant_id, row.grant_id); + assert.equal(decoded.state, row.state); + assert.equal(decoded.sequence, row.sequence); + } + for (const frame of fixture.frames) { + const decoded = Rich.decodeUnknownSync(Rich.CollectionFrame, 'strict')(frame); + assert.equal(decoded.collection, 'commands'); + const rows = decoded.items ?? decoded.upserts; + assert.equal(rows[0].kind, 'client-control-grant-lifecycle'); + } + for (const trace of fixture.traces) { + for (const step of trace.steps) { + if (step.expected === 'refuse-invalid-frame') { + assert.throws(() => Rich.decodeUnknownSync(Rich.Resource, 'strict')(step.resource)); + assert.throws(() => Rich.decodeUnknownSync(Rich.CollectionFrame, 'strict')({ ...fixture.frames[0], items: [step.resource] })); + } + } + } + for (const sequence of [0, Number.MAX_SAFE_INTEGER + 1]) { + assert.throws(() => Rich.decodeUnknownSync(Rich.Resource, 'strict')({ ...fixture.resources[0], sequence })); + } + assert.throws(() => Rich.decodeUnknownSync(Rich.Resource, 'strict')({ ...fixture.resources[0], state: 'future-state' })); +}); diff --git a/clients/typescript/fractal-scenarios/src/kit/fold.ts b/clients/typescript/fractal-scenarios/src/kit/fold.ts index 0c1c7a36200..836ca82a980 100644 --- a/clients/typescript/fractal-scenarios/src/kit/fold.ts +++ b/clients/typescript/fractal-scenarios/src/kit/fold.ts @@ -11,7 +11,7 @@ const upsert = (list: readonly A[], value: A): A[] => { } /** Complete client-v0 known-kind set; unsupported collections must not become future resources. */ -const COLLECTION_OF_KIND: Record = { +const COLLECTION_OF_KIND: Record = { agent: 'agents', runtime: 'runtimes', machine: 'machines', @@ -28,6 +28,7 @@ const COLLECTION_OF_KIND: Record = { lane: 'lanes', device: 'devices', 'device-command': 'deviceCommands', + 'client-control-grant-lifecycle': null, // Control metadata is not visible scenario state. operation: 'operations', history: 'history', session: 'sessions', @@ -41,9 +42,11 @@ const COLLECTION_OF_KIND: Record = { } const applyChanges = (state: S, upserts: readonly WireResource[], removes: readonly string[], order: readonly string[] | undefined): S => { + if (upserts.length > 0 && removes.length === 0 && upserts.every((value) => value.kind === 'client-control-grant-lifecycle')) return state const next = { ...state } as Record for (const value of upserts) { const key = Object.hasOwn(COLLECTION_OF_KIND, value.kind) ? COLLECTION_OF_KIND[value.kind as Resource['kind']] : undefined + if (key === null) continue if (key === undefined) next.resources = upsert((next.resources ?? []) as Keyed[], value) else { if (!Array.isArray(next[key])) throw new Error(`a ${value.kind} upsert has no place in this slice`) diff --git a/clients/typescript/fractal-scenarios/test/inventories.test.ts b/clients/typescript/fractal-scenarios/test/inventories.test.ts index 3064f440587..4ac12381284 100644 --- a/clients/typescript/fractal-scenarios/test/inventories.test.ts +++ b/clients/typescript/fractal-scenarios/test/inventories.test.ts @@ -84,4 +84,27 @@ describe('canonical inventories', () => { expect(nextRoster.state.devices[0]).toEqual(device) expect(nextDetails.state.launches[0]).toEqual(launch) }) + + it('does not fold control lifecycle metadata into visible scenario state', () => { + const { roster } = loadWorld('fleet-mid-refactor', { now: ANCHOR_MS }).slices + for (const state of ['started', 'ended', 'revoked'] as const) { + const lifecycle = { + id: 'client-control-grant/scenario-fixture/lifecycle', kind: 'client-control-grant-lifecycle' as const, + revision: '1', updated_at: new Date(ANCHOR_MS).toISOString(), + grant_id: 'client-control-grant/scenario-fixture', run_id: 'mission-run/scenario-fixture', + device: 'device/scenario-fixture', session: 'client-session/scenario-fixture', + state, reason: state === 'started' ? 'granted' : state === 'ended' ? 'run-ended' : 'revoked', sequence: 1, + } + const next = foldSlice({ ...roster, timeline: [{ + _tag: 'changes', at_ms: 1, store: 1, upserts: [lifecycle], removes: [], order: [lifecycle.id], + }] }, 1) + expect(next.state).toEqual(roster.state) + const device = { ...roster.state.devices[0]!, revision: 'device-2' } + const mixed = foldSlice({ ...roster, timeline: [{ + _tag: 'changes', at_ms: 1, store: 1, upserts: [lifecycle, device], removes: [], + }] }, 1) + expect(mixed.state.devices[0]).toEqual(device) + expect(mixed.state.resources).toEqual(roster.state.resources) + } + }) }) diff --git a/crates/fractal-client-codegen/src/main.rs b/crates/fractal-client-codegen/src/main.rs index 516ed9a5bd0..af0ef57dc8f 100644 --- a/crates/fractal-client-codegen/src/main.rs +++ b/crates/fractal-client-codegen/src/main.rs @@ -23,6 +23,14 @@ fn main() -> Result<()> { .skip(1) .any(|argument| argument == "--check"); let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + for (path, template) in [ + ("crates/fractal-schema/src/client_control.rs", include_str!("../templates/client_control.rs.in")), + ("clients/typescript/fractal-client/ClientControl.generated.ts", include_str!("../templates/ClientControl.ts.in")), + ("clients/swift/FractalClient/Sources/FractalClient/ClientControl.generated.swift", include_str!("../templates/ClientControl.swift.in")), + ("fixtures/clients/client-control-v1.golden.json", include_str!("../templates/client-control-v1.golden.json.in")), + ] { + output(&root.join(path), template, check)?; + } let operations_path = root.join("docs/st3/client-v0/schemas/operations.json"); let schema_path = root.join("docs/st3/client-v0/schemas/client-v0.schema.json"); let operations_bytes = std::fs::read(&operations_path).context("read operations manifest")?; @@ -752,7 +760,10 @@ fn validate_model( let command_body = definition == "DeviceCommand" && matches!(property.as_str(), "command_kind" | "args") && rust_block.contains("pub command: DeviceCommandBody") && rust.contains("#[serde(tag = \"command_kind\", content = \"args\")]"); - if !rust_block.contains(&format!("pub {rust_field}:")) && !rust_discriminated_timeline && !command_body { + let lifecycle_field = definition == "ClientControlGrantLifecycle" + && rust_block.contains("pub lifecycle: fractal_schema::client_control::ClientControlGrantLifecycleEvent") + && include_str!("../templates/client_control.rs.in").contains(&format!("pub {rust_field}:")); + if !rust_block.contains(&format!("pub {rust_field}:")) && !rust_discriminated_timeline && !command_body && !lifecycle_field { bail!("Rust `{rust_name}` does not model schema field `{property}`"); } let swift_field = match property.as_str() { diff --git a/crates/fractal-client-codegen/templates/Client.swift.in b/crates/fractal-client-codegen/templates/Client.swift.in index cf0251cacd9..3a84628d3f2 100644 --- a/crates/fractal-client-codegen/templates/Client.swift.in +++ b/crates/fractal-client-codegen/templates/Client.swift.in @@ -295,6 +295,7 @@ public actor FractalClient { /// Issue one expiring foreground command, without durable receipt or replay. public func deviceCommand(_ command: DeviceCommandRequest) async throws -> Envelope { try await post("v1/client/device-commands", command) } public func deviceResult(_ result: DeviceResult) async throws -> Envelope { try await post("v1/client/device-results", result) } + public func clientTestRevoke(device: String) async throws -> JSONValue { try await post("v1/client/test-clients/revoke", ["device": device]) } public func beginPairing(deviceName: String, personID: String) async throws -> Envelope { try await post("v1/client/pairings", PersonPairingBegin(apiVersion: fractalClientAPIVersion, deviceName: deviceName, personID: personID)) } public func beginPairing(_ request: PairingBegin) async throws -> Envelope { try await post("v1/client/pairings", request) } diff --git a/crates/fractal-client-codegen/templates/Client.ts.in b/crates/fractal-client-codegen/templates/Client.ts.in index ab5e2df734b..904d8385950 100644 --- a/crates/fractal-client-codegen/templates/Client.ts.in +++ b/crates/fractal-client-codegen/templates/Client.ts.in @@ -486,6 +486,10 @@ export class FractalClient { return this.request('POST', '/v1/client/device-results', result, undefined, undefined, requestOptions); } + async clientTestRevoke(device: string, requestOptions: RequestOptions = {}): Promise { + return this.request('POST', '/v1/client/test-clients/revoke', { device }, undefined, undefined, requestOptions); + } + async beginPairing(request: PairingBegin, requestOptions: RequestOptions = {}): Promise> { return this.request('POST', '/v1/client/pairings', request, undefined, undefined, requestOptions); } diff --git a/crates/fractal-client-codegen/templates/ClientControl.swift.in b/crates/fractal-client-codegen/templates/ClientControl.swift.in new file mode 100644 index 00000000000..4f6ed189984 --- /dev/null +++ b/crates/fractal-client-codegen/templates/ClientControl.swift.in @@ -0,0 +1,120 @@ +// Generated by cargo run -p fractal-client-codegen; DO NOT EDIT. +// Source: crates/fractal-client-codegen/templates/ClientControl.swift.in. +import Foundation +import CryptoKit + +public let clientResultPurpose = "client.device-result.v1" +public let clientResultSignedFields = ["purpose", "command_kind", "device", "session", "grant_id", "run_id", "command_id", "outcome", "error_code", "visible_state_sha256"] +public enum ClientControlRefusal: String, Codable, Sendable { + case deviceNotTestClient = "device-not-test-client", grantMissing = "grant-missing", grantExpired = "grant-expired" + case screenNotGranted = "screen-not-granted", verbNotGranted = "verb-not-granted", budgetExhausted = "budget-exhausted", actorNotGranted = "actor-not-granted" +} +public struct ClientTestEnrollment: Codable, Sendable { + public var device: String; public var kind: String; public var enrolledBy: String + public var consentRef: String; public var packetDigest: String; public var host: String; public var instance: String + public var pairingSubject: String; public var publicKey: String; public var session: String +} +public struct ClientTestRevocation: Codable, Sendable { + public var device: String; public var enrollmentId: String; public var revokedBy: String +} +public struct ClientCommandReceipt: Codable, Sendable { + public var commandId: String; public var terminalState: DeviceCommandOutcome + public var issuer: String; public var device: String; public var session: String? + public var grantId: String?; public var runId: String?; public var commandKind: String + public var screen: String?; public var reason: ClientControlRefusal? + public var verifiedResultSignature: DeviceSignature?; public var signedResult: ClientResultBinding? + private enum CodingKeys: String, CodingKey { case commandId, terminalState, issuer, device, session, grantId, runId, commandKind, screen, reason, verifiedResultSignature, signedResult } + public func encode(to encoder: Encoder) throws { + var box = encoder.container(keyedBy: CodingKeys.self) + try box.encode(commandId, forKey: .commandId); try box.encode(terminalState, forKey: .terminalState) + try box.encode(issuer, forKey: .issuer); try box.encode(device, forKey: .device); try box.encodeIfPresent(session, forKey: .session) + try box.encodeIfPresent(grantId, forKey: .grantId); try box.encodeIfPresent(runId, forKey: .runId); try box.encode(commandKind, forKey: .commandKind) + try box.encodeIfPresent(screen, forKey: .screen); try box.encodeIfPresent(reason, forKey: .reason) + if let verifiedResultSignature { try box.encode(verifiedResultSignature, forKey: .verifiedResultSignature) } + else { try box.encodeNil(forKey: .verifiedResultSignature) } + if let signedResult { try box.encode(signedResult, forKey: .signedResult) } + else { try box.encodeNil(forKey: .signedResult) } + } +} +public struct ClientVisibleItem: Codable, Sendable, Equatable { + public var id: String; public var title: String; public var body: String + public init(id: String, title: String, body: String) { self.id = id; self.title = title; self.body = body } +} +public struct ClientVisibleState: Codable, Sendable, Equatable { + public var route: String; public var screen: String; public var visible_item_ids: [String]; public var opened_item: ClientVisibleItem? + public init(route: String, screen: String, visible_item_ids: [String], opened_item: ClientVisibleItem?) { + self.route = route; self.screen = screen; self.visible_item_ids = visible_item_ids; self.opened_item = opened_item + } + private enum CodingKeys: String, CodingKey { case route, screen, visible_item_ids, opened_item } + public func encode(to encoder: Encoder) throws { + var box = encoder.container(keyedBy: CodingKeys.self) + try box.encode(route, forKey: .route); try box.encode(screen, forKey: .screen) + try box.encode(visible_item_ids, forKey: .visible_item_ids) + if let opened_item { try box.encode(opened_item, forKey: .opened_item) } + else { try box.encodeNil(forKey: .opened_item) } + } +} +public struct ClientResultBinding: Codable, Sendable, Equatable { + public var purpose: String; public var command_kind: String; public var device: String; public var session: String + public var grant_id: String; public var run_id: String; public var command_id: String; public var outcome: String + public var error_code: String?; public var visible_state: ClientVisibleState + public init(purpose: String = clientResultPurpose, command_kind: String, device: String, session: String, grant_id: String, run_id: String, command_id: String, outcome: String, error_code: String?, visible_state: ClientVisibleState) { + self.purpose = purpose; self.command_kind = command_kind; self.device = device; self.session = session + self.grant_id = grant_id; self.run_id = run_id; self.command_id = command_id; self.outcome = outcome + self.error_code = error_code; self.visible_state = visible_state + } + private enum CodingKeys: String, CodingKey { + case purpose, command_kind, device, session, grant_id, run_id, command_id, outcome, error_code, visible_state + } + public func encode(to encoder: Encoder) throws { + var box = encoder.container(keyedBy: CodingKeys.self) + try box.encode(purpose, forKey: .purpose); try box.encode(command_kind, forKey: .command_kind) + try box.encode(device, forKey: .device); try box.encode(session, forKey: .session) + try box.encode(grant_id, forKey: .grant_id); try box.encode(run_id, forKey: .run_id) + try box.encode(command_id, forKey: .command_id); try box.encode(outcome, forKey: .outcome) + if let error_code { try box.encode(error_code, forKey: .error_code) } + else { try box.encodeNil(forKey: .error_code) } + try box.encode(visible_state, forKey: .visible_state) + } +} +public struct ClientControlGrant: Codable, Sendable, Equatable { + public var grant_id: String; public var run_id: String; public var device: String; public var session: String; public var actor_agent: String + public var verbs: [String]; public var screens: [String]; public var max_commands: UInt64; public var expires_at: String; public var state: String + public var item_id: String? +} +private func clientResultQuote(_ text: String) -> String { + var result = "\"" + for scalar in text.unicodeScalars { + switch scalar.value { + case 0x22: result += "\\\"" + case 0x5c: result += "\\\\" + case 8: result += "\\b" + case 9: result += "\\t" + case 10: result += "\\n" + case 12: result += "\\f" + case 13: result += "\\r" + case 0...31: result += String(format: "\\u%04x", scalar.value) + default: result.unicodeScalars.append(scalar) + } + } + return result + "\"" +} +/// RFC 8785 JCS for the generated closed string-only visible-state schema. +public func visibleStateCanonicalJson(_ state: ClientVisibleState) -> String { + let item = state.opened_item.map { "{\"body\":" + clientResultQuote($0.body) + ",\"id\":" + clientResultQuote($0.id) + ",\"title\":" + clientResultQuote($0.title) + "}" } ?? "null" + return "{\"opened_item\":" + item + ",\"route\":" + clientResultQuote(state.route) + ",\"screen\":" + clientResultQuote(state.screen) + ",\"visible_item_ids\":[" + state.visible_item_ids.map(clientResultQuote).joined(separator: ",") + "]}" +} +public func visibleStateDigest(_ state: ClientVisibleState) -> String { + SHA256.hash(data: Data(visibleStateCanonicalJson(state).utf8)).map { String(format: "%02x", $0) }.joined() +} +/// Sign with P256.Signing.PrivateKey.signature(for:).rawRepresentation (r||s). +/// This domain is never message.send authorization. Enforce active grant first. +public func clientResultCanonicalBytes(_ binding: ClientResultBinding) -> Data { + let fields: [(String, String?)] = [ + ("purpose", binding.purpose), ("command_kind", binding.command_kind), ("device", binding.device), + ("session", binding.session), ("grant_id", binding.grant_id), ("run_id", binding.run_id), + ("command_id", binding.command_id), ("outcome", binding.outcome), ("error_code", binding.error_code), + ("visible_state_sha256", visibleStateDigest(binding.visible_state)) + ] + return Data((["smallclaims-claim-fields-v1"] + fields.map { $0.0 + "=" + ($0.1.map(clientResultQuote) ?? "null") }).joined(separator: "\n").utf8) +} diff --git a/crates/fractal-client-codegen/templates/ClientControl.ts.in b/crates/fractal-client-codegen/templates/ClientControl.ts.in new file mode 100644 index 00000000000..9024c9c6c24 --- /dev/null +++ b/crates/fractal-client-codegen/templates/ClientControl.ts.in @@ -0,0 +1,60 @@ +// Generated by cargo run -p fractal-client-codegen; DO NOT EDIT. +// Source: crates/fractal-client-codegen/templates/ClientControl.ts.in. +export const CLIENT_RESULT_PURPOSE = 'client.device-result.v1'; +export const CLIENT_RESULT_SIGNED_FIELDS = ['purpose', 'command_kind', 'device', 'session', 'grant_id', 'run_id', 'command_id', 'outcome', 'error_code', 'visible_state_sha256'] as const; +export interface ClientVisibleItem { id: string; title: string; body: string } +export interface ClientVisibleState { route: string; screen: string; visible_item_ids: string[]; opened_item: ClientVisibleItem | null } +export interface ClientResultBinding { + purpose: string; command_kind: string; device: string; session: string; + grant_id: string; run_id: string; command_id: string; outcome: string; + error_code: string | null; visible_state: ClientVisibleState; +} +export interface ClientControlGrant { + grant_id: string; run_id: string; device: string; session: string; actor_agent: string; + verbs: string[]; screens: string[]; max_commands: number; expires_at: string; state: string; + item_id?: string; +} +export class ClientResultCanonicalizationError extends Error { + readonly code = 'invalid-unicode' as const; + constructor() { + super('Invalid Unicode in client result'); + this.name = 'ClientResultCanonicalizationError'; + } +} +function quote(value: string): string { + // RFC 8785 requires rejection of lone surrogates rather than replacement. + for (let i = 0; i < value.length; i++) { + const c = value.charCodeAt(i); + if (c >= 0xd800 && c <= 0xdbff) { + const next = value.charCodeAt(++i); + if (!(next >= 0xdc00 && next <= 0xdfff)) throw new ClientResultCanonicalizationError(); + } else if (c >= 0xdc00 && c <= 0xdfff) throw new ClientResultCanonicalizationError(); + } + return JSON.stringify(value); +} +/** RFC 8785 JCS for the generated closed string-only visible-state schema. */ +export function visibleStateCanonicalJson(state: ClientVisibleState): string { + const item = state.opened_item; + return '{"opened_item":' + (item === null ? 'null' : '{"body":' + quote(item.body) + ',"id":' + quote(item.id) + ',"title":' + quote(item.title) + '}') + + ',"route":' + quote(state.route) + ',"screen":' + quote(state.screen) + + ',"visible_item_ids":[' + state.visible_item_ids.map(quote).join(',') + ']}'; +} +export type ClientControlSha256 = (bytes: Uint8Array) => Promise; +export const defaultWebCryptoSha256: ClientControlSha256 = async bytes => + new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(bytes).buffer)); +export async function visibleStateDigest(state: ClientVisibleState, sha256: ClientControlSha256 = defaultWebCryptoSha256): Promise { + const digest = await sha256(new TextEncoder().encode(visibleStateCanonicalJson(state))); + return Array.from(digest, b => b.toString(16).padStart(2, '0')).join(''); +} +/** Sign using ECDSA P-256/SHA-256, IEEE P1363 r||s (64 bytes), base64url without padding. + * Never use this helper as message.send authorization. Enforce active grant first. */ +export async function clientResultCanonicalBytes(binding: ClientResultBinding, sha256: ClientControlSha256 = defaultWebCryptoSha256): Promise { + const digest = await visibleStateDigest(binding.visible_state, sha256); + const fields: [string, string | null][] = [ + ['purpose', binding.purpose], ['command_kind', binding.command_kind], ['device', binding.device], + ['session', binding.session], ['grant_id', binding.grant_id], ['run_id', binding.run_id], + ['command_id', binding.command_id], ['outcome', binding.outcome], ['error_code', binding.error_code], + ['visible_state_sha256', digest], + ]; + return new TextEncoder().encode(['smallclaims-claim-fields-v1', ...fields.map(([k, v]) => k + '=' + (v === null ? 'null' : quote(v)))].join('\n')); +} diff --git a/crates/fractal-client-codegen/templates/Models.swift.in b/crates/fractal-client-codegen/templates/Models.swift.in index f67d5ffb1f3..c4366d073bb 100644 --- a/crates/fractal-client-codegen/templates/Models.swift.in +++ b/crates/fractal-client-codegen/templates/Models.swift.in @@ -791,20 +791,23 @@ public struct DeviceCommandRequest: Codable, Sendable { public let apiVersion, device: String public let command: PhoneCommand public let ttlMS: UInt64? - public init(device: String, command: PhoneCommand, ttlMS: UInt64? = nil, apiVersion: String = "st3.client.v0") { - self.apiVersion = apiVersion; self.device = device; self.command = command; self.ttlMS = ttlMS + public let grantID: String? + public init(device: String, command: PhoneCommand, ttlMS: UInt64? = nil, grantID: String? = nil, apiVersion: String = "st3.client.v0") { + self.apiVersion = apiVersion; self.device = device; self.command = command; self.ttlMS = ttlMS; self.grantID = grantID } - private enum Keys: String, CodingKey { case apiVersion = "api_version", device, ttlMS = "ttl_ms" } + private enum Keys: String, CodingKey { case apiVersion = "api_version", device, ttlMS = "ttl_ms", grantID = "grant_id" } public init(from decoder: Decoder) throws { let box = try decoder.container(keyedBy: Keys.self) apiVersion = try box.decode(String.self, forKey: .apiVersion) device = try box.decode(String.self, forKey: .device) ttlMS = try box.decodeIfPresent(UInt64.self, forKey: .ttlMS) + grantID = try box.decodeIfPresent(String.self, forKey: .grantID) command = try PhoneCommand(from: decoder) } public func encode(to encoder: Encoder) throws { var box = encoder.container(keyedBy: Keys.self) try box.encode(apiVersion, forKey: .apiVersion); try box.encode(device, forKey: .device) + try box.encodeIfPresent(grantID, forKey: .grantID) try box.encodeIfPresent(ttlMS, forKey: .ttlMS); try command.encode(to: encoder) } } @@ -813,10 +816,12 @@ public struct DeviceResult: Codable, Sendable { public let outcome: DeviceCommandOutcome public let result: JSONValue? public let errorCode: String? - public init(commandID: String, outcome: DeviceCommandOutcome, result: JSONValue? = nil, errorCode: String? = nil) { - self.commandID = commandID; self.outcome = outcome; self.result = result; self.errorCode = errorCode + public let binding: ClientResultBinding? + public let signature: DeviceSignature? + public init(commandID: String, outcome: DeviceCommandOutcome, result: JSONValue? = nil, errorCode: String? = nil, binding: ClientResultBinding? = nil, signature: DeviceSignature? = nil) { + self.commandID = commandID; self.outcome = outcome; self.result = result; self.errorCode = errorCode; self.binding = binding; self.signature = signature } - enum CodingKeys: String, CodingKey { case commandID = "command_id", outcome, result, errorCode = "error_code" } + enum CodingKeys: String, CodingKey { case commandID = "command_id", outcome, result, errorCode = "error_code", binding, signature } } public struct DeviceCommand: Codable, Sendable, Identifiable { public let id, kind, revision, updatedAt, deviceID, sessionActor, issuedBy, issuedAt, expiresAt: String @@ -824,9 +829,14 @@ public struct DeviceCommand: Codable, Sendable, Identifiable { public let state: DeviceCommandState public let result: JSONValue? public let errorCode: String? + public let grantID, runID: String? + public let controlGrant: ClientControlGrant? + public let receiptClaimID: String? private enum Keys: String, CodingKey { case id, kind, revision, updatedAt = "updated_at", deviceID = "device_id", sessionActor = "session_actor" case issuedBy = "issued_by", issuedAt = "issued_at", expiresAt = "expires_at", state, result, errorCode = "error_code" + case grantID = "grant_id", runID = "run_id", controlGrant = "control_grant" + case receiptClaimID = "receipt_claim_id" } public init(from decoder: Decoder) throws { let box = try decoder.container(keyedBy: Keys.self) @@ -836,6 +846,9 @@ public struct DeviceCommand: Codable, Sendable, Identifiable { issuedBy = try box.decode(String.self, forKey: .issuedBy); issuedAt = try box.decode(String.self, forKey: .issuedAt) expiresAt = try box.decode(String.self, forKey: .expiresAt); state = try box.decode(DeviceCommandState.self, forKey: .state) result = try box.decodeIfPresent(JSONValue.self, forKey: .result); errorCode = try box.decodeIfPresent(String.self, forKey: .errorCode) + grantID = try box.decodeIfPresent(String.self, forKey: .grantID); runID = try box.decodeIfPresent(String.self, forKey: .runID) + controlGrant = try box.decodeIfPresent(ClientControlGrant.self, forKey: .controlGrant) + receiptClaimID = try box.decodeIfPresent(String.self, forKey: .receiptClaimID) command = try PhoneCommand(from: decoder, resource: true) } public func encode(to encoder: Encoder) throws { @@ -846,6 +859,9 @@ public struct DeviceCommand: Codable, Sendable, Identifiable { try box.encode(issuedBy, forKey: .issuedBy); try box.encode(issuedAt, forKey: .issuedAt) try box.encode(expiresAt, forKey: .expiresAt); try box.encode(state, forKey: .state) try box.encodeIfPresent(result, forKey: .result); try box.encodeIfPresent(errorCode, forKey: .errorCode) + try box.encodeIfPresent(grantID, forKey: .grantID); try box.encodeIfPresent(runID, forKey: .runID) + try box.encodeIfPresent(controlGrant, forKey: .controlGrant) + try box.encodeIfPresent(receiptClaimID, forKey: .receiptClaimID) try command.encode(to: encoder, resource: true) } } @@ -994,11 +1010,50 @@ public enum HarnessInvalidAnswerReason: String, Codable, Sendable { case unknown public struct HarnessInvalidAnswerDetails: Codable, Sendable { public let reason: HarnessInvalidAnswerReason } public enum HarnessNotAnswerableReason: String, Codable, Sendable { case adapterMissing = "adapter_missing", adapterUnavailable = "adapter_unavailable" } public struct HarnessNotAnswerableDetails: Codable, Sendable { public let reason: HarnessNotAnswerableReason } +public enum ClientControlGrantLifecycleState: String, Codable, Sendable { case started, ended, revoked } +public struct ClientControlGrantLifecycleResource: Codable, Sendable, Identifiable { + public let id, kind, revision, updatedAt, grantID, runID, device, session: String + public let itemID: String? + public let state: ClientControlGrantLifecycleState + public let reason: String + public let sequence: UInt64 + enum CodingKeys: String, CodingKey { + case id, kind, revision, updatedAt = "updated_at", grantID = "grant_id", runID = "run_id", itemID = "item_id", device, session, state, reason, sequence + } + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) + kind = try values.decode(String.self, forKey: .kind) + revision = try values.decode(String.self, forKey: .revision) + updatedAt = try values.decode(String.self, forKey: .updatedAt) + grantID = try values.decode(String.self, forKey: .grantID) + runID = try values.decode(String.self, forKey: .runID) + device = try values.decode(String.self, forKey: .device) + session = try values.decode(String.self, forKey: .session) + itemID = try values.decodeIfPresent(String.self, forKey: .itemID) + state = try values.decode(ClientControlGrantLifecycleState.self, forKey: .state) + reason = try values.decode(String.self, forKey: .reason) + sequence = try values.decode(UInt64.self, forKey: .sequence) + guard sequence >= 1 && sequence <= 9_007_199_254_740_991 else { + throw DecodingError.dataCorruptedError(forKey: .sequence, in: values, debugDescription: "Lifecycle sequence must be a positive JSON-safe integer") + } + } +} +/// Existing commands snapshot/changes frames; lifecycle rows share Resource decoding. +public struct ClientCommandsCollectionFrame: Codable, Sendable { + public let kind, id, collection: String + public let snapshot: Snapshot + public let items, upserts: [Resource]? + public let removes, order: [String]? + public let hasMore: Bool + enum CodingKeys: String, CodingKey { case kind, id, collection, snapshot, items, upserts, removes, order, hasMore = "has_more" } +} public enum Resource: Codable, Sendable, Identifiable { case harnessQuestion(HarnessQuestionResource) case connectedClient(ConnectedClientResource), usage(UsageResource) case summary(SummaryResource) case deviceCommand(DeviceCommand) + case clientControlGrantLifecycle(ClientControlGrantLifecycleResource) case attention(AttentionResource), message(MessageResource), launch(LaunchResource), launchVariant(LaunchVariantResource), launchDecision(LaunchDecisionResource), launchApproval(LaunchApprovalResource), mission(MissionResource), work(WorkResource), agent(AgentResource), runtime(RuntimeResource), observer(ObserverResource), subscription(SubscriptionResource), lane(LaneResource), machine(MachineResource), device(DeviceResource), operation(OperationResource), history(HistoryResource), session(SessionResource), glass(GlassResource), ownedSet(OwnedSetResource) /// A kind this client does not know, from a member newer than it: kept whole so an older /// client skips it or shows it plainly instead of failing the page it came in. @@ -1027,6 +1082,7 @@ public enum Resource: Codable, Sendable, Identifiable { case "machine": self = .machine(try MachineResource(from: decoder)) case "device": self = .device(try DeviceResource(from: decoder)) case "device-command": self = .deviceCommand(try DeviceCommand(from: decoder)) + case "client-control-grant-lifecycle": self = .clientControlGrantLifecycle(try ClientControlGrantLifecycleResource(from: decoder)) case "operation": self = .operation(try OperationResource(from: decoder)) case "history": self = .history(try HistoryResource(from: decoder)) case "session": self = .session(try SessionResource(from: decoder)) @@ -1058,6 +1114,7 @@ public enum Resource: Codable, Sendable, Identifiable { case .machine(let v): try v.encode(to: encoder) case .device(let v): try v.encode(to: encoder) case .deviceCommand(let v): try v.encode(to: encoder) + case .clientControlGrantLifecycle(let v): try v.encode(to: encoder) case .operation(let v): try v.encode(to: encoder) case .history(let v): try v.encode(to: encoder) case .session(let v): try v.encode(to: encoder) @@ -1067,7 +1124,7 @@ public enum Resource: Codable, Sendable, Identifiable { case .unknown(let v): try v.encode(to: encoder) } } - public var id: String { switch self { case .harnessQuestion(let v): v.id; case .connectedClient(let v): v.id; case .usage(let v): v.id; case .summary(let v): v.id; case .attention(let v): v.id; case .message(let v): v.id; case .launch(let v): v.id; case .launchVariant(let v): v.id; case .launchDecision(let v): v.id; case .launchApproval(let v): v.id; case .mission(let v): v.id; case .work(let v): v.id; case .agent(let v): v.id; case .runtime(let v): v.id; case .observer(let v): v.id; case .subscription(let v): v.id; case .lane(let v): v.id; case .machine(let v): v.id; case .device(let v): v.id; case .deviceCommand(let v): v.id; case .operation(let v): v.id; case .history(let v): v.id; case .session(let v): v.id; case .glass(let v): v.id; case .arrangement(let v): v.id; case .ownedSet(let v): v.id; case .unknown(let v): v.id } } + public var id: String { switch self { case .clientControlGrantLifecycle(let v): v.id; case .harnessQuestion(let v): v.id; case .connectedClient(let v): v.id; case .usage(let v): v.id; case .summary(let v): v.id; case .attention(let v): v.id; case .message(let v): v.id; case .launch(let v): v.id; case .launchVariant(let v): v.id; case .launchDecision(let v): v.id; case .launchApproval(let v): v.id; case .mission(let v): v.id; case .work(let v): v.id; case .agent(let v): v.id; case .runtime(let v): v.id; case .observer(let v): v.id; case .subscription(let v): v.id; case .lane(let v): v.id; case .machine(let v): v.id; case .device(let v): v.id; case .deviceCommand(let v): v.id; case .operation(let v): v.id; case .history(let v): v.id; case .session(let v): v.id; case .glass(let v): v.id; case .arrangement(let v): v.id; case .ownedSet(let v): v.id; case .unknown(let v): v.id } } } /// A resource of a kind this client does not model: its header, and every field as sent. public struct UnknownResource: Codable, Sendable, Identifiable { diff --git a/crates/fractal-client-codegen/templates/client-control-v1.golden.json.in b/crates/fractal-client-codegen/templates/client-control-v1.golden.json.in new file mode 100644 index 00000000000..97bd57cb9ff --- /dev/null +++ b/crates/fractal-client-codegen/templates/client-control-v1.golden.json.in @@ -0,0 +1,501 @@ +{ + "_generated": "cargo run -p fractal-client-codegen; source templates/client-control-v1.golden.json.in; fixed PUBLIC TEST key scalar 1, nonce scalar 2, never use for enrollment", + "format": "client-control-v1", + "public_key_sec1_base64url": "BGsX0fLhLEJH-Lzm5WOkQPJ3A32BLeszoPShOUXYmMKWT-NC4v4af5uO5-tKfA-eFivOM1drMV7Oy7ZAaDe_UfU", + "private_scalar_hex_for_tests": "0000000000000000000000000000000000000000000000000000000000000001", + "message_domain_bytes": "smallclaims-claim-fields-v1\nmessage/test\nmessage.sent\nperson/client-control-fixture\ncontent=\"hello\"", + "message_domain_signature_p1363_base64url": "fPJ7GI0DT36KUjgDBLUaw8CJaeJ38hs1pgtI_EdmmXgRe11q295ks9iyv8QCIwcWwVU7WhRTUdQe_JG0MhGy1w", + "vectors": [ + { + "name": "inbox", + "input": { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + "canonical_visible_state": "{\"opened_item\":null,\"route\":\"/inbox\",\"screen\":\"NowRoot\",\"visible_item_ids\":[\"step-run/ask-golden/ask\"]}", + "visible_state_sha256": "4cfea1d08a82607334d7c43e6781b5ae1c2c5066145a5965391e99994a9fd453", + "canonical_bytes": "smallclaims-claim-fields-v1\npurpose=\"client.device-result.v1\"\ncommand_kind=\"phone.inspect\"\ndevice=\"device/test-tui\"\nsession=\"client-session/test-tui\"\ngrant_id=\"client-control-grant/golden\"\nrun_id=\"mission-run/golden\"\ncommand_id=\"device-command/golden-0\"\noutcome=\"completed\"\nerror_code=null\nvisible_state_sha256=\"4cfea1d08a82607334d7c43e6781b5ae1c2c5066145a5965391e99994a9fd453\"", + "signature_p1363_base64url": "fPJ7GI0DT36KUjgDBLUaw8CJaeJ38hs1pgtI_EdmmXiuu76h6230PGyTZfFujp1lxwC70zSTNzoxlic27FPzmg", + "negative_bindings": [ + { + "purpose": "message.send", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/other", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/other", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/other", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.message.read", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/other", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/other", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "failed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": "tampered", + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/tampered", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + } + ] + }, + { + "name": "unicode-opened-item", + "input": { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + "canonical_visible_state": "{\"opened_item\":{\"body\":\"Line 1\\n\\t\\\"\\\\\\u0000\\u001f<>/

\",\"id\":\"step-run/ask-golden/ask\",\"title\":\"Seed é 😀\"},\"route\":\"/attention/step-run/ask-golden/ask\",\"screen\":\"Attention\",\"visible_item_ids\":[\"step-run/ask-golden/ask\",\"message/é😀\"]}", + "visible_state_sha256": "2203b99e4166cda8d04df74ed0c00043490699105058540edb39b86dbf094db3", + "canonical_bytes": "smallclaims-claim-fields-v1\npurpose=\"client.device-result.v1\"\ncommand_kind=\"phone.navigate\"\ndevice=\"device/test-tui\"\nsession=\"client-session/test-tui\"\ngrant_id=\"client-control-grant/golden\"\nrun_id=\"mission-run/golden\"\ncommand_id=\"device-command/golden-1\"\noutcome=\"completed\"\nerror_code=null\nvisible_state_sha256=\"2203b99e4166cda8d04df74ed0c00043490699105058540edb39b86dbf094db3\"", + "signature_p1363_base64url": "fPJ7GI0DT36KUjgDBLUaw8CJaeJ38hs1pgtI_EdmmXi45Ckt8qvn0tLGFBvQ7AEZ-86R5CQtnzYJQ5kea6paXQ", + "negative_bindings": [ + { + "purpose": "message.send", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/other", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/other", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/other", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.message.read", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/other", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/other", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "failed", + "error_code": null, + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": "tampered", + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/tampered", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + } + ] + } + ] +} diff --git a/crates/fractal-client-codegen/templates/client_control.rs.in b/crates/fractal-client-codegen/templates/client_control.rs.in new file mode 100644 index 00000000000..c4fde6a50bb --- /dev/null +++ b/crates/fractal-client-codegen/templates/client_control.rs.in @@ -0,0 +1,148 @@ +// Generated by cargo run -p fractal-client-codegen; DO NOT EDIT. +// Source: crates/fractal-client-codegen/templates/client_control.rs.in. +//! Purpose-specific client results. This is not a message signing authorization. +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; + +pub const CLIENT_RESULT_PURPOSE: &str = "client.device-result.v1"; +pub const CLIENT_RESULT_SIGNED_FIELDS: &[&str] = &[ + "purpose", "command_kind", "device", "session", "grant_id", "run_id", + "command_id", "outcome", "error_code", "visible_state_sha256", +]; +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ClientControlGrantLifecycleState { Started, Ended, Revoked } +/// Session-scoped payload carried only by the existing commands collection. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct ClientControlGrantLifecycleEvent { + pub grant_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] pub item_id: Option, + pub run_id: String, + pub device: String, + pub session: String, + pub state: ClientControlGrantLifecycleState, + pub reason: String, + #[serde(deserialize_with = "deserialize_lifecycle_sequence")] + pub sequence: u64, +} +fn deserialize_lifecycle_sequence<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { + let sequence = u64::deserialize(deserializer)?; + if !(1..=(1_u64 << 53) - 1).contains(&sequence) { + return Err(serde::de::Error::custom("lifecycle sequence must be a positive JSON-safe integer")); + } + Ok(sequence) +} +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ClientControlRefusal { + DeviceNotTestClient, GrantMissing, GrantExpired, ScreenNotGranted, + VerbNotGranted, BudgetExhausted, ActorNotGranted, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ClientTestEnrollment { + pub device: String, pub kind: String, pub enrolled_by: String, + pub consent_ref: String, pub packet_digest: String, pub host: String, + pub instance: String, pub pairing_subject: String, pub public_key: String, pub session: String, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ClientTestRevocation { + pub device: String, pub enrollment_id: String, pub revoked_by: String, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ClientVisibleItem { pub id: String, pub title: String, pub body: String } +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ClientVisibleState { + pub route: String, + pub screen: String, + pub visible_item_ids: Vec, + pub opened_item: Option, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ClientResultBinding { + pub purpose: String, + pub command_kind: String, + pub device: String, + pub session: String, + pub grant_id: String, + pub run_id: String, + pub command_id: String, + pub outcome: String, + pub error_code: Option, + pub visible_state: ClientVisibleState, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ClientControlGrant { + pub grant_id: String, + pub run_id: String, + pub device: String, + pub session: String, + pub actor_agent: String, + pub verbs: Vec, + pub screens: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] pub item_id: Option, + pub max_commands: u64, + pub expires_at: String, + pub state: String, +} +/// RFC 8785 JCS for this closed, string-only schema. Keys are emitted in ASCII +/// order explicitly, independent of serde_json's preserve_order feature. +/// There are no numbers or arbitrary object keys in this type. +pub fn visible_state_canonical_json(state: &ClientVisibleState) -> Result { + #[derive(Serialize)] + struct Item<'a> { body: &'a str, id: &'a str, title: &'a str } + #[derive(Serialize)] + struct State<'a> { opened_item: Option>, route: &'a str, screen: &'a str, visible_item_ids: &'a [String] } + serde_json::to_string(&State { + opened_item: state.opened_item.as_ref().map(|item| Item { body: &item.body, id: &item.id, title: &item.title }), + route: &state.route, screen: &state.screen, visible_item_ids: &state.visible_item_ids, + }) +} +pub fn visible_state_digest(state: &ClientVisibleState) -> Result { + Ok(hex::encode(Sha256::digest(visible_state_canonical_json(state)?.as_bytes()))) +} +/// Purpose is the first fields-v1 field. Values are JSON, not raw lines, so +/// embedded newlines cannot inject fields. No trailing newline is signed. +/// Callers MUST enforce the purpose constant, enrolled key, grant and windows. +pub fn client_result_canonical_bytes(binding: &ClientResultBinding) -> Result, serde_json::Error> { + let digest = visible_state_digest(&binding.visible_state)?; + let mut lines = vec!["smallclaims-claim-fields-v1".to_owned()]; + for (name, value) in [ + ("purpose", Some(binding.purpose.as_str())), + ("command_kind", Some(binding.command_kind.as_str())), + ("device", Some(binding.device.as_str())), + ("session", Some(binding.session.as_str())), + ("grant_id", Some(binding.grant_id.as_str())), + ("run_id", Some(binding.run_id.as_str())), + ("command_id", Some(binding.command_id.as_str())), + ("outcome", Some(binding.outcome.as_str())), + ("error_code", binding.error_code.as_deref()), + ("visible_state_sha256", Some(digest.as_str())), + ] { lines.push(format!("{name}={}", serde_json::to_string(&value)?)); } + Ok(lines.join("\n").into_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn shared_golden_vectors() { + let fixture: serde_json::Value = serde_json::from_str(include_str!("../../../fixtures/clients/client-control-v1.golden.json")).unwrap(); + for vector in fixture["vectors"].as_array().unwrap() { + let binding: ClientResultBinding = serde_json::from_value(vector["input"].clone()).unwrap(); + assert_eq!(visible_state_canonical_json(&binding.visible_state).unwrap(), vector["canonical_visible_state"].as_str().unwrap()); + assert_eq!(visible_state_digest(&binding.visible_state).unwrap(), vector["visible_state_sha256"].as_str().unwrap()); + let bytes = client_result_canonical_bytes(&binding).unwrap(); + assert_eq!(bytes, vector["canonical_bytes"].as_str().unwrap().as_bytes()); + for mutation in vector["negative_bindings"].as_array().unwrap() { + let changed: ClientResultBinding = serde_json::from_value(mutation.clone()).unwrap(); + assert_ne!(bytes, client_result_canonical_bytes(&changed).unwrap()); + } + } + } +} diff --git a/crates/fractal-client-codegen/templates/generated.rs.in b/crates/fractal-client-codegen/templates/generated.rs.in index a2a87f21f7d..8e29251ab9b 100644 --- a/crates/fractal-client-codegen/templates/generated.rs.in +++ b/crates/fractal-client-codegen/templates/generated.rs.in @@ -2856,6 +2856,7 @@ pub enum Resource { Machine(Machine), Device(Device), DeviceCommand(DeviceCommand), + ClientControlGrantLifecycle(ClientControlGrantLifecycle), Operation(Operation), History(History), Session(Session), @@ -2891,6 +2892,7 @@ pub const KNOWN_RESOURCE_KINDS: &[&str] = &[ "machine", "device", "device-command", + "client-control-grant-lifecycle", "operation", "history", "session", @@ -2960,6 +2962,7 @@ impl Resource { Self::Machine(v) => &v.header, Self::Device(v) => &v.header, Self::DeviceCommand(v) => &v.header, + Self::ClientControlGrantLifecycle(v) => &v.header, Self::Operation(v) => &v.header, Self::History(v) => &v.header, Self::Session(v) => &v.header, @@ -5263,6 +5266,7 @@ pub enum PhoneCommand { pub struct DeviceCommandRequest { pub api_version: String, pub device: String, + #[serde(default, skip_serializing_if = "Option::is_none")] pub grant_id: Option, #[serde(flatten)] pub command: PhoneCommand, #[serde(default, skip_serializing_if = "Option::is_none")] pub ttl_ms: Option, } @@ -5286,6 +5290,24 @@ pub struct DeviceResult { pub outcome: DeviceCommandOutcome, #[serde(default, skip_serializing_if = "Option::is_none")] pub result: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub error_code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub binding: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub signature: Option, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ClientCommandReceipt { + pub command_id: String, + pub terminal_state: DeviceCommandOutcome, + pub issuer: String, + pub device: String, + #[serde(default, skip_serializing_if = "Option::is_none")] pub session: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub grant_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub run_id: Option, + pub command_kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] pub screen: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub reason: Option, + pub verified_result_signature: Option, + pub signed_result: Option, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub struct DeviceCommand { @@ -5296,7 +5318,21 @@ pub struct DeviceCommand { pub issued_by: String, pub issued_at: String, pub expires_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] pub grant_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub run_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub control_grant: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub receipt_claim_id: Option, pub state: DeviceCommandState, #[serde(default, skip_serializing_if = "Option::is_none")] pub result: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub error_code: Option, } + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub struct ClientControlGrantLifecycle { + #[serde(flatten)] pub header: ResourceHeader, + #[serde(flatten)] pub lifecycle: fractal_schema::client_control::ClientControlGrantLifecycleEvent, +} +impl std::ops::Deref for ClientControlGrantLifecycle { + type Target = fractal_schema::client_control::ClientControlGrantLifecycleEvent; + fn deref(&self) -> &Self::Target { &self.lifecycle } +} diff --git a/crates/fractal-client-codegen/templates/lib.rs.in b/crates/fractal-client-codegen/templates/lib.rs.in index cb69c4c2fae..70d437ebf3d 100644 --- a/crates/fractal-client-codegen/templates/lib.rs.in +++ b/crates/fractal-client-codegen/templates/lib.rs.in @@ -17,6 +17,7 @@ pub mod propagation; pub mod scenarios; pub use contract::*; pub use generated::*; +pub use fractal_schema::client_control::*; mod identities; pub use identities::*; @@ -2023,6 +2024,10 @@ impl Client { self.post("/v1/client/device-results", result).await } + pub async fn client_test_revoke(&self, device: &str) -> Result { + self.post("/v1/client/test-clients/revoke", &serde_json::json!({"device": device})).await + } + pub async fn pairing_begin( &self, request: &PairingBegin, diff --git a/crates/fractal-client/src/generated.rs b/crates/fractal-client/src/generated.rs index c26061f4283..e61201fecdc 100644 --- a/crates/fractal-client/src/generated.rs +++ b/crates/fractal-client/src/generated.rs @@ -2857,6 +2857,7 @@ pub enum Resource { Machine(Machine), Device(Device), DeviceCommand(DeviceCommand), + ClientControlGrantLifecycle(ClientControlGrantLifecycle), Operation(Operation), History(History), Session(Session), @@ -2892,6 +2893,7 @@ pub const KNOWN_RESOURCE_KINDS: &[&str] = &[ "machine", "device", "device-command", + "client-control-grant-lifecycle", "operation", "history", "session", @@ -2961,6 +2963,7 @@ impl Resource { Self::Machine(v) => &v.header, Self::Device(v) => &v.header, Self::DeviceCommand(v) => &v.header, + Self::ClientControlGrantLifecycle(v) => &v.header, Self::Operation(v) => &v.header, Self::History(v) => &v.header, Self::Session(v) => &v.header, @@ -6175,6 +6178,8 @@ pub enum PhoneCommand { pub struct DeviceCommandRequest { pub api_version: String, pub device: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub grant_id: Option, #[serde(flatten)] pub command: PhoneCommand, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -6219,6 +6224,31 @@ pub struct DeviceResult { pub result: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub error_code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub binding: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signature: Option, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ClientCommandReceipt { + pub command_id: String, + pub terminal_state: DeviceCommandOutcome, + pub issuer: String, + pub device: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub grant_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run_id: Option, + pub command_kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub screen: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + pub verified_result_signature: Option, + pub signed_result: Option, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub struct DeviceCommand { @@ -6231,9 +6261,31 @@ pub struct DeviceCommand { pub issued_by: String, pub issued_at: String, pub expires_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub grant_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub control_grant: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receipt_claim_id: Option, pub state: DeviceCommandState, #[serde(default, skip_serializing_if = "Option::is_none")] pub result: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub error_code: Option, } + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub struct ClientControlGrantLifecycle { + #[serde(flatten)] + pub header: ResourceHeader, + #[serde(flatten)] + pub lifecycle: fractal_schema::client_control::ClientControlGrantLifecycleEvent, +} +impl std::ops::Deref for ClientControlGrantLifecycle { + type Target = fractal_schema::client_control::ClientControlGrantLifecycleEvent; + fn deref(&self) -> &Self::Target { + &self.lifecycle + } +} diff --git a/crates/fractal-client/src/lib.rs b/crates/fractal-client/src/lib.rs index acacfab4677..3c5841bea44 100644 --- a/crates/fractal-client/src/lib.rs +++ b/crates/fractal-client/src/lib.rs @@ -16,6 +16,7 @@ pub mod propagation; pub mod quota; pub mod scenarios; pub use contract::*; +pub use fractal_schema::client_control::*; pub use generated::*; mod identities; pub use identities::*; @@ -3689,6 +3690,14 @@ impl Client { self.post("/v1/client/device-results", result).await } + pub async fn client_test_revoke(&self, device: &str) -> Result { + self.post( + "/v1/client/test-clients/revoke", + &serde_json::json!({"device": device}), + ) + .await + } + pub async fn pairing_begin( &self, request: &PairingBegin, diff --git a/crates/fractal-client/tests/client_control_golden.rs b/crates/fractal-client/tests/client_control_golden.rs new file mode 100644 index 00000000000..c0b5c76248a --- /dev/null +++ b/crates/fractal-client/tests/client_control_golden.rs @@ -0,0 +1,30 @@ +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use fractal_client::{ClientResultBinding, client_result_canonical_bytes, visible_state_canonical_json, visible_state_digest}; +use ring::signature::{ECDSA_P256_SHA256_FIXED, UnparsedPublicKey}; + +#[test] +fn all_clients_share_canonical_bytes_and_p256_signatures() { + let fixture: serde_json::Value = serde_json::from_str(include_str!("../../../fixtures/clients/client-control-v1.golden.json")).unwrap(); + let key = URL_SAFE_NO_PAD.decode(fixture["public_key_sec1_base64url"].as_str().unwrap()).unwrap(); + let verifier = UnparsedPublicKey::new(&ECDSA_P256_SHA256_FIXED, &key); + for vector in fixture["vectors"].as_array().unwrap() { + let binding: ClientResultBinding = serde_json::from_value(vector["input"].clone()).unwrap(); + assert_eq!(visible_state_canonical_json(&binding.visible_state).unwrap(), vector["canonical_visible_state"].as_str().unwrap()); + assert_eq!(visible_state_digest(&binding.visible_state).unwrap(), vector["visible_state_sha256"].as_str().unwrap()); + let canonical = client_result_canonical_bytes(&binding).unwrap(); + assert_eq!(canonical, vector["canonical_bytes"].as_str().unwrap().as_bytes()); + let signature = URL_SAFE_NO_PAD.decode(vector["signature_p1363_base64url"].as_str().unwrap()).unwrap(); + verifier.verify(&canonical, &signature).unwrap(); + for negative in vector["negative_bindings"].as_array().unwrap() { + let changed: ClientResultBinding = serde_json::from_value(negative.clone()).unwrap(); + assert!(verifier.verify(&client_result_canonical_bytes(&changed).unwrap(), &signature).is_err()); + } + // message.send's ordinary fields-v1 domain cannot consume a result signature. + let message = fixture["message_domain_bytes"].as_str().unwrap().as_bytes(); + assert!(verifier.verify(message, &signature).is_err()); + // The exact same enrolled test key signed this message-domain vector. + let message_signature = URL_SAFE_NO_PAD.decode(fixture["message_domain_signature_p1363_base64url"].as_str().unwrap()).unwrap(); + verifier.verify(message, &message_signature).unwrap(); + assert!(verifier.verify(&canonical, &message_signature).is_err()); + } +} diff --git a/crates/fractal-client/tests/client_control_lifecycle.rs b/crates/fractal-client/tests/client_control_lifecycle.rs new file mode 100644 index 00000000000..f9a27b6bdc2 --- /dev/null +++ b/crates/fractal-client/tests/client_control_lifecycle.rs @@ -0,0 +1,42 @@ +use fractal_client::{CollectionEvent, Resource}; + +#[test] +fn commands_collection_decodes_typed_grant_lifecycle_vectors() { + let vectors: serde_json::Value = serde_json::from_str(include_str!("../../../fixtures/clients/client-control-lifecycle-v1.json")).unwrap(); + for row in vectors["resources"].as_array().unwrap() { + let resource: Resource = serde_json::from_value(row.clone()).unwrap(); + let Resource::ClientControlGrantLifecycle(lifecycle) = resource else { panic!("lifecycle must never decode as Unknown") }; + assert_eq!(lifecycle.grant_id, row["grant_id"].as_str().unwrap()); + assert_eq!(lifecycle.item_id.as_deref(), row["item_id"].as_str()); + assert_eq!(lifecycle.sequence, row["sequence"].as_u64().unwrap()); + assert_eq!(serde_json::to_value(lifecycle.lifecycle.state).unwrap(), row["state"]); + } + for frame in vectors["frames"].as_array().unwrap() { + let event: CollectionEvent = serde_json::from_value(frame.clone()).unwrap(); + let (collection, rows) = match event { + CollectionEvent::Snapshot { collection, items, .. } => (collection, items), + CollectionEvent::Changes { collection, upserts, .. } => (collection, upserts), + _ => panic!("lifecycle uses existing snapshot/changes frames only"), + }; + assert_eq!(collection, "commands"); + assert!(matches!(&rows[0], Resource::ClientControlGrantLifecycle(_))); + } + for trace in vectors["traces"].as_array().unwrap() { + for step in trace["steps"].as_array().unwrap() { + if step["expected"] == "refuse-invalid-frame" { + assert!(serde_json::from_value::(step["resource"].clone()).is_err()); + let mut frame = vectors["frames"][0].clone(); + frame["items"] = serde_json::json!([step["resource"]]); + assert!(serde_json::from_value::(frame).is_err()); + } + } + } + for sequence in [0, 1_u64 << 53] { + let mut invalid = vectors["resources"][0].clone(); + invalid["sequence"] = sequence.into(); + assert!(serde_json::from_value::(invalid).is_err()); + } + let mut invalid = vectors["resources"][0].clone(); + invalid["state"] = "future-state".into(); + assert!(serde_json::from_value::(invalid).is_err()); +} diff --git a/crates/fractal-schema/src/client_control.rs b/crates/fractal-schema/src/client_control.rs new file mode 100644 index 00000000000..c4fde6a50bb --- /dev/null +++ b/crates/fractal-schema/src/client_control.rs @@ -0,0 +1,148 @@ +// Generated by cargo run -p fractal-client-codegen; DO NOT EDIT. +// Source: crates/fractal-client-codegen/templates/client_control.rs.in. +//! Purpose-specific client results. This is not a message signing authorization. +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; + +pub const CLIENT_RESULT_PURPOSE: &str = "client.device-result.v1"; +pub const CLIENT_RESULT_SIGNED_FIELDS: &[&str] = &[ + "purpose", "command_kind", "device", "session", "grant_id", "run_id", + "command_id", "outcome", "error_code", "visible_state_sha256", +]; +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ClientControlGrantLifecycleState { Started, Ended, Revoked } +/// Session-scoped payload carried only by the existing commands collection. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct ClientControlGrantLifecycleEvent { + pub grant_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] pub item_id: Option, + pub run_id: String, + pub device: String, + pub session: String, + pub state: ClientControlGrantLifecycleState, + pub reason: String, + #[serde(deserialize_with = "deserialize_lifecycle_sequence")] + pub sequence: u64, +} +fn deserialize_lifecycle_sequence<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { + let sequence = u64::deserialize(deserializer)?; + if !(1..=(1_u64 << 53) - 1).contains(&sequence) { + return Err(serde::de::Error::custom("lifecycle sequence must be a positive JSON-safe integer")); + } + Ok(sequence) +} +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ClientControlRefusal { + DeviceNotTestClient, GrantMissing, GrantExpired, ScreenNotGranted, + VerbNotGranted, BudgetExhausted, ActorNotGranted, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ClientTestEnrollment { + pub device: String, pub kind: String, pub enrolled_by: String, + pub consent_ref: String, pub packet_digest: String, pub host: String, + pub instance: String, pub pairing_subject: String, pub public_key: String, pub session: String, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ClientTestRevocation { + pub device: String, pub enrollment_id: String, pub revoked_by: String, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ClientVisibleItem { pub id: String, pub title: String, pub body: String } +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ClientVisibleState { + pub route: String, + pub screen: String, + pub visible_item_ids: Vec, + pub opened_item: Option, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ClientResultBinding { + pub purpose: String, + pub command_kind: String, + pub device: String, + pub session: String, + pub grant_id: String, + pub run_id: String, + pub command_id: String, + pub outcome: String, + pub error_code: Option, + pub visible_state: ClientVisibleState, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ClientControlGrant { + pub grant_id: String, + pub run_id: String, + pub device: String, + pub session: String, + pub actor_agent: String, + pub verbs: Vec, + pub screens: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] pub item_id: Option, + pub max_commands: u64, + pub expires_at: String, + pub state: String, +} +/// RFC 8785 JCS for this closed, string-only schema. Keys are emitted in ASCII +/// order explicitly, independent of serde_json's preserve_order feature. +/// There are no numbers or arbitrary object keys in this type. +pub fn visible_state_canonical_json(state: &ClientVisibleState) -> Result { + #[derive(Serialize)] + struct Item<'a> { body: &'a str, id: &'a str, title: &'a str } + #[derive(Serialize)] + struct State<'a> { opened_item: Option>, route: &'a str, screen: &'a str, visible_item_ids: &'a [String] } + serde_json::to_string(&State { + opened_item: state.opened_item.as_ref().map(|item| Item { body: &item.body, id: &item.id, title: &item.title }), + route: &state.route, screen: &state.screen, visible_item_ids: &state.visible_item_ids, + }) +} +pub fn visible_state_digest(state: &ClientVisibleState) -> Result { + Ok(hex::encode(Sha256::digest(visible_state_canonical_json(state)?.as_bytes()))) +} +/// Purpose is the first fields-v1 field. Values are JSON, not raw lines, so +/// embedded newlines cannot inject fields. No trailing newline is signed. +/// Callers MUST enforce the purpose constant, enrolled key, grant and windows. +pub fn client_result_canonical_bytes(binding: &ClientResultBinding) -> Result, serde_json::Error> { + let digest = visible_state_digest(&binding.visible_state)?; + let mut lines = vec!["smallclaims-claim-fields-v1".to_owned()]; + for (name, value) in [ + ("purpose", Some(binding.purpose.as_str())), + ("command_kind", Some(binding.command_kind.as_str())), + ("device", Some(binding.device.as_str())), + ("session", Some(binding.session.as_str())), + ("grant_id", Some(binding.grant_id.as_str())), + ("run_id", Some(binding.run_id.as_str())), + ("command_id", Some(binding.command_id.as_str())), + ("outcome", Some(binding.outcome.as_str())), + ("error_code", binding.error_code.as_deref()), + ("visible_state_sha256", Some(digest.as_str())), + ] { lines.push(format!("{name}={}", serde_json::to_string(&value)?)); } + Ok(lines.join("\n").into_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn shared_golden_vectors() { + let fixture: serde_json::Value = serde_json::from_str(include_str!("../../../fixtures/clients/client-control-v1.golden.json")).unwrap(); + for vector in fixture["vectors"].as_array().unwrap() { + let binding: ClientResultBinding = serde_json::from_value(vector["input"].clone()).unwrap(); + assert_eq!(visible_state_canonical_json(&binding.visible_state).unwrap(), vector["canonical_visible_state"].as_str().unwrap()); + assert_eq!(visible_state_digest(&binding.visible_state).unwrap(), vector["visible_state_sha256"].as_str().unwrap()); + let bytes = client_result_canonical_bytes(&binding).unwrap(); + assert_eq!(bytes, vector["canonical_bytes"].as_str().unwrap().as_bytes()); + for mutation in vector["negative_bindings"].as_array().unwrap() { + let changed: ClientResultBinding = serde_json::from_value(mutation.clone()).unwrap(); + assert_ne!(bytes, client_result_canonical_bytes(&changed).unwrap()); + } + } + } +} diff --git a/crates/fractal-schema/src/lib.rs b/crates/fractal-schema/src/lib.rs index 8b822a5387e..82eeaaf63d8 100644 --- a/crates/fractal-schema/src/lib.rs +++ b/crates/fractal-schema/src/lib.rs @@ -1,5 +1,6 @@ //! The authoritative st3 subject, resource, and claim registry. +pub mod client_control; pub mod arrangements; pub mod custom; pub mod accountable; diff --git a/docs/st3/client-v0/device-commands.md b/docs/st3/client-v0/device-commands.md index 8067214e7a7..6fef362096b 100644 --- a/docs/st3/client-v0/device-commands.md +++ b/docs/st3/client-v0/device-commands.md @@ -127,3 +127,139 @@ available for five minutes of ephemeral correlation before eviction. The process discard a retained outcome early to make room. A daemon restart or eviction makes outcomes unavailable. Expired commands are never executed on reconnection. The normal envelope's snapshot describes the response context, not durable storage of the command. + +## Isolated client-control v1 signed results + +The generated client-control contract uses the same `phone.inspect` and +`phone.navigate` verbs on iOS, web and TUI. `phone.message.read` is excluded from +test grants. A granted request adds `grant_id`; its command carries `grant_id`, +`run_id` and `control_grant` (including the pairing `session` actor). Ordinary +ungranted reports retain the preceding wire shape. + +A granted `DeviceResult` adds `binding: ClientResultBinding` and the existing +`signature: DeviceSignature` envelope. The binding contains `purpose`, +`command_kind`, `device`, `session`, `grant_id`, `run_id`, `command_id`, +`outcome`, nullable `error_code`, and `visible_state`. Outer command, outcome and +error must agree with the binding; any outer `result` must equal `visible_state`. +The closed visible-state type has `route`, `screen`, `visible_item_ids` and +nullable `opened_item: {id, title, body}`. Arrays retain display order. + +`visible_state_sha256` is lowercase SHA-256 of UTF-8 RFC 8785 JCS JSON. The +closed type admits only strings, null, arrays and fixed ASCII object keys: +there is no floating-point normalization or arbitrary-key sorting ambiguity. +No Unicode normalization is applied, lone surrogates are invalid, and slash, +non-ASCII and U+2028/U+2029 are not escaped. + +Sign UTF-8 newline-joined lines below, **without a trailing newline**. Every +right-hand value is its compact JSON encoding, including quotes for strings. +The purpose is the first signed field, and the field order is normative: + +```text +smallclaims-claim-fields-v1 +purpose="client.device-result.v1" +command_kind="phone.inspect" +device="device/example" +session="client-session/example" +grant_id="client-control-grant/example" +run_id="mission-run/example" +command_id="device-command/example" +outcome="completed" +error_code=null +visible_state_sha256="" +``` + +Use P-256 ECDSA with SHA-256; signature encoding is 64-byte IEEE P1363 `r||s`, +base64url without padding, not ASN.1 DER. The envelope format is `fields-v1` and +`signed_fields` is exactly the ordered list above. The enrolled pairing key is +the authority, never a caller-selected envelope key. Envelope `nonce` and +`signed_at_unix_ms` are not signed or freshness authority: daemon time must be +within both command and grant windows, and a command accepts only one result. +This signature is purpose-specific and never authorizes `message.send` or a +read-state mutation; fixture pairings must not acquire `control.messages`. + +The source templates live in `crates/fractal-client-codegen/templates`; regenerate +all outputs with `cargo run -p fractal-client-codegen`. Rust exports canonical +helpers through `fractal_schema::client_control` and `fractal_client`; TypeScript +uses `ClientControl.generated.ts`, and Swift uses `ClientControl.generated.swift`. +All three test suites read the same +`fixtures/clients/client-control-v1.golden.json`, including fixed-public-test-key +signatures and negative bindings for grant/run/command/purpose/kind/device/session, +outcome, error and visible-state tampering. The fixture scalar and nonce are public +test data, never enrollment keys. + +## Fixture enrollment and revocation + +Enrollment is a privileged local Unix-socket operation on the local-only +`client-control-fixture` instance owned by `person/client-control-fixture`. +The socket peer must have the daemon's UID. A remote bearer credential, +request header, ordinary pairing, or main-instance operator cannot enroll. +Each isolated client first generates its own P-256 key and uses the existing +pairing flow. Enrollment binds the exact existing pairing subject, device and +public key; it never generates keys or grants principal signing authority. +Pairing scopes must include `read.projections` and `control.commands`, with +only `read.*` scopes otherwise; `control.messages` is forbidden. + +The enrollment request records `device`, `pairing_subject`, `public_key`, +`kind` (`ios`, `web`, `tui`), `host`, `instance`, `consent_ref` and +`packet_digest`. The instance is exactly `client-control-fixture`. +`consent_ref` must name the native ask `step-run/ask-…/ask`; `packet_digest` +is the lowercase 64-hex SHA256 of the reviewed enrollment packet and input +manifest. These become `consentRef` and `packetDigest` in the persisted +`custom.client.test-enrolled` claim alongside the bound host, instance and kind. +Invalid ask IDs return `consent-ref-invalid`; malformed digests return +`consent-packet-invalid`; reuse across devices, kinds or re-enrollment returns +`consent-ref-reused`. Re-enrollment or changed scope requires a fresh ask. + +Approval verification is an external mission gate: before the local call, +verify the native ask was answered by `person/schickling` with explicit approval +of the matching packet digest, kind, host and instance. Silence is not approval. +The privileged operator attests that verified gate; the fixture daemon never +connects to the main daemon or imports its person/ask claims. A signed exported +consent receipt is not part of this protocol. + +The exact paired client's revoke control, a local agent, or the daemon-host +operator appends `custom.client.test-revoked` naming the enrollment claim ID. +This persisted withdrawal ends every grant bound to that enrollment, regardless +of timestamps; new enrollment cannot resurrect old grants. Grant issue, command +admission and result acceptance reload enrollment and pairing authority. +Operators can revoke even after the pairing expires. + +## Grant lifecycle on the commands collection + +The existing session-scoped `commands` subscription carries generated +`client-control-grant-lifecycle` resources alongside device commands, using the +same snapshot/changes frames and typed `Resource` decoder. Each lifecycle row +has the ordinary resource header plus `grant_id`, optional `item_id`, `run_id`, +`device`, `session`, `state` (`started`, `ended`, `revoked`), `reason` and a +monotonic integer `sequence` in `1...9007199254740991`. All client decoders +reject zero or out-of-range sequences before lifecycle ordering checks; the +shared zero-sequence trace expects `refuse-invalid-frame`, not a stale start. + +These rows drive the visible indicator and termination only. They do not carry +verbs, screens or budget policy; policy remains in each +`DeviceCommand.control_grant`. A command without an active, nonterminal +lifecycle grant for its `grant_id` must produce no navigation or signature. +When a grant binds `item_id`, every lifecycle row must carry that exact item. +Clients ignore started rows whose sequence is not greater than the last +observed sequence for the grant, and treat ended/revoked as permanently final +for that grant ID: a later started row cannot reactivate it. + +`fixtures/clients/client-control-lifecycle-v1.json` is shared decoder and +acceptance-trace data for all clients, covering each state, stale/reordered +started rows, missing/wrong item binding, command-before-start and +command-after-terminal. Context-dependent lifecycle checks belong to the +client handler; decoding a resource never grants authority. +Scenario folding ignores lifecycle-only control metadata rather than changing +the visible resource state. + +TypeScript canonical hashing supports Hermes without WebCrypto through an +injected `ClientControlSha256 = (bytes: Uint8Array) => Promise`. +Pass it as the second argument to `visibleStateDigest` or +`clientResultCanonicalBytes`; omitting it uses generated +`defaultWebCryptoSha256`. Canonical encoding remains generated and unchanged; +the injected implementation returns the raw 32 SHA-256 bytes, not hex or text. +The default WebCrypto adapter copies input into an owned `ArrayBuffer`; injected +hashers retain the general `Uint8Array` input contract. +Invalid Unicode is refused with the exported TypeScript +`ClientResultCanonicalizationError` and its `code: "invalid-unicode"`, including +through `clientResultCanonicalBytes`; consumers must not match error messages. diff --git a/docs/st3/client-v0/schemas/client-v0.schema.json b/docs/st3/client-v0/schemas/client-v0.schema.json index 3daf13f9002..510f985eefd 100644 --- a/docs/st3/client-v0/schemas/client-v0.schema.json +++ b/docs/st3/client-v0/schemas/client-v0.schema.json @@ -457,18 +457,77 @@ "DeviceCommandRequest": { "description": "One expiring foreground command. Default TTL is 30000 ms, maximum 60000 ms; at most eight pending commands per device.", "oneOf": [ - {"type": "object", "additionalProperties": false, "required": ["api_version", "device", "kind", "args"], "properties": {"api_version": {"enum": ["st3.client.v0", "fractal.client.v0"]}, "device": {"type": "string", "pattern": "^device/.+"}, "kind": {"const": "phone.inspect"}, "args": {"$ref": "#/$defs/PhoneInspectArgs"}, "ttl_ms": {"type": "integer", "minimum": 1, "maximum": 60000, "default": 30000}}}, - {"type": "object", "additionalProperties": false, "required": ["api_version", "device", "kind", "args"], "properties": {"api_version": {"enum": ["st3.client.v0", "fractal.client.v0"]}, "device": {"type": "string", "pattern": "^device/.+"}, "kind": {"const": "phone.navigate"}, "args": {"$ref": "#/$defs/PhoneNavigateArgs"}, "ttl_ms": {"type": "integer", "minimum": 1, "maximum": 60000, "default": 30000}}}, - {"type": "object", "additionalProperties": false, "required": ["api_version", "device", "kind", "args"], "properties": {"api_version": {"enum": ["st3.client.v0", "fractal.client.v0"]}, "device": {"type": "string", "pattern": "^device/.+"}, "kind": {"const": "phone.message.read"}, "args": {"$ref": "#/$defs/PhoneMessageReadArgs"}, "ttl_ms": {"type": "integer", "minimum": 1, "maximum": 60000, "default": 30000}}} + {"type": "object", "additionalProperties": false, "required": ["api_version", "device", "kind", "args"], "properties": {"api_version": {"enum": ["st3.client.v0", "fractal.client.v0"]}, "device": {"type": "string", "pattern": "^device/.+"}, "grant_id": {"type": "string"}, "kind": {"const": "phone.inspect"}, "args": {"$ref": "#/$defs/PhoneInspectArgs"}, "ttl_ms": {"type": "integer", "minimum": 1, "maximum": 60000, "default": 30000}}}, + {"type": "object", "additionalProperties": false, "required": ["api_version", "device", "kind", "args"], "properties": {"api_version": {"enum": ["st3.client.v0", "fractal.client.v0"]}, "device": {"type": "string", "pattern": "^device/.+"}, "grant_id": {"type": "string"}, "kind": {"const": "phone.navigate"}, "args": {"$ref": "#/$defs/PhoneNavigateArgs"}, "ttl_ms": {"type": "integer", "minimum": 1, "maximum": 60000, "default": 30000}}}, + {"type": "object", "additionalProperties": false, "required": ["api_version", "device", "kind", "args"], "properties": {"api_version": {"enum": ["st3.client.v0", "fractal.client.v0"]}, "device": {"type": "string", "pattern": "^device/.+"}, "grant_id": {"type": "string"}, "kind": {"const": "phone.message.read"}, "args": {"$ref": "#/$defs/PhoneMessageReadArgs"}, "ttl_ms": {"type": "integer", "minimum": 1, "maximum": 60000, "default": 30000}}} ] }, "DeviceCommandOutcome": {"enum": ["completed", "not-foreground", "expired", "refused", "failed"]}, "DeviceCommandState": {"enum": ["pending", "delivered", "completed", "not-foreground", "expired", "refused", "failed"]}, + "ClientVisibleItem": { + "type": "object", "additionalProperties": false, "required": ["id", "title", "body"], + "properties": {"id": {"type": "string"}, "title": {"type": "string"}, "body": {"type": "string"}} + }, + "ClientVisibleState": { + "type": "object", "additionalProperties": false, "required": ["route", "screen", "visible_item_ids", "opened_item"], + "properties": {"route": {"type": "string"}, "screen": {"type": "string"}, "visible_item_ids": {"type": "array", "items": {"type": "string"}}, "opened_item": {"anyOf": [{"$ref": "#/$defs/ClientVisibleItem"}, {"type": "null"}]}} + }, + "ClientResultBinding": { + "type": "object", "additionalProperties": false, + "required": ["purpose", "command_kind", "device", "session", "grant_id", "run_id", "command_id", "outcome", "error_code", "visible_state"], + "properties": { + "purpose": {"const": "client.device-result.v1"}, "command_kind": {"enum": ["phone.inspect", "phone.navigate"]}, + "device": {"type": "string"}, "session": {"type": "string"}, "grant_id": {"type": "string"}, "run_id": {"type": "string"}, "command_id": {"type": "string"}, + "outcome": {"$ref": "#/$defs/DeviceCommandOutcome"}, "error_code": {"type": ["string", "null"]}, "visible_state": {"$ref": "#/$defs/ClientVisibleState"} + } + }, + "ClientControlGrant": { + "type": "object", "additionalProperties": false, + "required": ["grant_id", "run_id", "device", "session", "actor_agent", "verbs", "screens", "max_commands", "expires_at", "state"], + "properties": { + "grant_id": {"type": "string"}, "run_id": {"type": "string"}, "device": {"type": "string"}, "session": {"type": "string"}, "actor_agent": {"type": "string"}, + "verbs": {"type": "array", "items": {"type": "string"}}, "screens": {"type": "array", "items": {"type": "string"}}, + "item_id": {"type": "string"}, + "max_commands": {"type": "integer", "minimum": 1, "maximum": 9007199254740991}, "expires_at": {"$ref": "#/$defs/Timestamp"}, "state": {"type": "string"} + } + }, + "ClientControlRefusal": {"enum": ["device-not-test-client", "grant-missing", "grant-expired", "screen-not-granted", "verb-not-granted", "budget-exhausted", "actor-not-granted"]}, + "ClientTestEnrollment": { + "type": "object", "additionalProperties": false, + "required": ["device", "kind", "enrolledBy", "consentRef", "packetDigest", "host", "instance", "pairingSubject", "publicKey", "session"], + "properties": { + "device": {"type": "string"}, "kind": {"enum": ["ios", "web", "tui"]}, "enrolledBy": {"type": "string"}, "consentRef": {"type": "string"}, + "packetDigest": {"type": "string"}, "host": {"type": "string"}, "instance": {"type": "string"}, "pairingSubject": {"type": "string"}, "publicKey": {"type": "string"}, "session": {"type": "string"} + } + }, + "ClientTestRevocation": { + "type": "object", "additionalProperties": false, "required": ["device", "enrollmentId", "revokedBy"], + "properties": {"device": {"type": "string"}, "enrollmentId": {"type": "string"}, "revokedBy": {"type": "string"}} + }, + "ClientCommandReceipt": { + "type": "object", "additionalProperties": false, "required": ["commandId", "terminalState", "issuer", "device", "commandKind", "verifiedResultSignature", "signedResult"], + "properties": { + "commandId": {"type": "string"}, "terminalState": {"$ref": "#/$defs/DeviceCommandOutcome"}, "issuer": {"type": "string"}, "device": {"type": "string"}, + "session": {"type": "string"}, "grantId": {"type": "string"}, "runId": {"type": "string"}, "commandKind": {"type": "string"}, + "screen": {"type": "string"}, "reason": {"$ref": "#/$defs/ClientControlRefusal"}, + "verifiedResultSignature": {"anyOf": [{"$ref": "#/$defs/DeviceSignature"}, {"type": "null"}]}, "signedResult": {"anyOf": [{"$ref": "#/$defs/ClientResultBinding"}, {"type": "null"}]} + } + }, "DeviceResult": { "type": "object", "additionalProperties": false, "required": ["command_id", "outcome"], - "properties": {"command_id": {"type": "string", "pattern": "^device-command/.+"}, "outcome": {"$ref": "#/$defs/DeviceCommandOutcome"}, "result": {}, "error_code": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9_.-]+$"}}, + "properties": {"command_id": {"type": "string", "pattern": "^device-command/.+"}, "outcome": {"$ref": "#/$defs/DeviceCommandOutcome"}, "result": {}, "error_code": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9_.-]+$"}, "binding": {"$ref": "#/$defs/ClientResultBinding"}, "signature": {"$ref": "#/$defs/DeviceSignature"}}, "description": "Terminal phone report; authenticated pairing supplies the device identity. The daemon clock adjudicates expiry." }, + "ClientControlGrantLifecycle": { + "allOf": [{"$ref": "#/$defs/ResourceHeader"}], "type": "object", + "required": ["id", "kind", "revision", "updated_at", "grant_id", "run_id", "device", "session", "state", "reason", "sequence"], + "properties": { + "kind": {"const": "client-control-grant-lifecycle"}, "grant_id": {"type": "string"}, "item_id": {"type": "string"}, + "run_id": {"type": "string"}, "device": {"type": "string"}, "session": {"type": "string"}, + "state": {"enum": ["started", "ended", "revoked"]}, "reason": {"type": "string"}, + "sequence": {"type": "integer", "minimum": 1, "maximum": 9007199254740991} + } + }, "DeviceCommand": { "allOf": [{"$ref": "#/$defs/ResourceHeader"}], "type": "object", @@ -481,6 +540,8 @@ "device_id": {"type": "string", "pattern": "^device/.+"}, "session_actor": {"$ref": "#/$defs/Id"}, "issued_by": {"$ref": "#/$defs/Id"}, "issued_at": {"$ref": "#/$defs/Timestamp"}, "expires_at": {"$ref": "#/$defs/Timestamp"}, + "grant_id": {"type": "string"}, "run_id": {"type": "string"}, "control_grant": {"$ref": "#/$defs/ClientControlGrant"}, + "receipt_claim_id": {"type": "string"}, "state": {"$ref": "#/$defs/DeviceCommandState"}, "result": {}, "error_code": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9_.-]+$"} }, "oneOf": [ @@ -691,6 +752,7 @@ "machine", "device", "device-command", + "client-control-grant-lifecycle", "operation", "history", "session", @@ -3121,6 +3183,7 @@ { "$ref": "#/$defs/DeviceCommand" }, + {"$ref": "#/$defs/ClientControlGrantLifecycle"}, { "$ref": "#/$defs/Operation" }, diff --git a/fixtures/clients/client-control-lifecycle-v1.json b/fixtures/clients/client-control-lifecycle-v1.json new file mode 100644 index 00000000000..ef6d355fcad --- /dev/null +++ b/fixtures/clients/client-control-lifecycle-v1.json @@ -0,0 +1,313 @@ +{ + "version": 1, + "rules": [ + "A bound grant item_id must be present and equal on every lifecycle row.", + "Started sequence must be strictly greater than the last observed sequence for that grant_id.", + "Ended and revoked are final; a subsequent started row never reactivates that grant_id.", + "Lifecycle only controls indicator/termination; each command still requires its own DeviceCommand.control_grant policy.", + "No active nonterminal lifecycle for command.grant_id means refuse without navigation or signing." + ], + "resources": [ + { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "1", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "item_id": "step-run/ask-golden/ask", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "started", + "reason": "granted", + "sequence": 1 + }, + { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "2", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "item_id": "step-run/ask-golden/ask", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "ended", + "reason": "run-ended", + "sequence": 2 + }, + { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "3", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "item_id": "step-run/ask-golden/ask", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "revoked", + "reason": "revoked", + "sequence": 3 + } + ], + "frames": [ + { + "kind": "snapshot", + "id": "commands", + "collection": "commands", + "snapshot": { + "id": "snapshot/golden", + "host_id": "host/fixture", + "store_index": 1, + "projection_version": "client-projection.v0", + "created_at": "2026-11-10T12:00:00Z" + }, + "order": [ + "client-control-grant-lifecycle/golden" + ], + "has_more": false, + "items": [ + { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "1", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "item_id": "step-run/ask-golden/ask", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "started", + "reason": "granted", + "sequence": 1 + } + ] + }, + { + "kind": "changes", + "id": "commands", + "collection": "commands", + "snapshot": { + "id": "snapshot/golden", + "host_id": "host/fixture", + "store_index": 2, + "projection_version": "client-projection.v0", + "created_at": "2026-11-10T12:00:00Z" + }, + "order": [ + "client-control-grant-lifecycle/golden" + ], + "has_more": false, + "upserts": [ + { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "2", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "item_id": "step-run/ask-golden/ask", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "ended", + "reason": "run-ended", + "sequence": 2 + } + ], + "removes": [] + }, + { + "kind": "changes", + "id": "commands", + "collection": "commands", + "snapshot": { + "id": "snapshot/golden", + "host_id": "host/fixture", + "store_index": 3, + "projection_version": "client-projection.v0", + "created_at": "2026-11-10T12:00:00Z" + }, + "order": [ + "client-control-grant-lifecycle/golden" + ], + "has_more": false, + "upserts": [ + { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "3", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "item_id": "step-run/ask-golden/ask", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "revoked", + "reason": "revoked", + "sequence": 3 + } + ], + "removes": [] + } + ], + "traces": [ + { + "grant_id": "client-control-grant/golden", + "item_id": "step-run/ask-golden/ask", + "steps": [ + { + "command_grant_id": "client-control-grant/golden", + "expected": "refuse-no-active-lifecycle" + }, + { + "resource": { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "1", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "item_id": "step-run/ask-golden/ask", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "started", + "reason": "granted", + "sequence": 1 + }, + "expected": "activate" + }, + { + "resource": { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "1", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "item_id": "step-run/ask-golden/ask", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "started", + "reason": "granted", + "sequence": 1 + }, + "expected": "ignore-stale-started" + }, + { + "resource": { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "0", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "item_id": "step-run/ask-golden/ask", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "started", + "reason": "granted", + "sequence": 0 + }, + "expected": "refuse-invalid-frame" + }, + { + "resource": { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "2", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "item_id": "step-run/other/ask", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "started", + "reason": "granted", + "sequence": 2 + }, + "expected": "ignore-item-mismatch" + }, + { + "resource": { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "2", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "started", + "reason": "granted", + "sequence": 2 + }, + "expected": "ignore-item-mismatch" + }, + { + "command_grant_id": "client-control-grant/golden", + "expected": "allow-lifecycle-gate-only" + }, + { + "resource": { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "2", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "item_id": "step-run/ask-golden/ask", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "ended", + "reason": "run-ended", + "sequence": 2 + }, + "expected": "terminate" + }, + { + "command_grant_id": "client-control-grant/golden", + "expected": "refuse-terminal-lifecycle" + }, + { + "resource": { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "4", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "item_id": "step-run/ask-golden/ask", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "started", + "reason": "granted", + "sequence": 4 + }, + "expected": "ignore-final-grant" + }, + { + "resource": { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "3", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "item_id": "step-run/ask-golden/ask", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "revoked", + "reason": "revoked", + "sequence": 3 + }, + "expected": "remain-terminal" + }, + { + "command_grant_id": "client-control-grant/golden", + "expected": "refuse-terminal-lifecycle" + } + ] + } + ] +} diff --git a/fixtures/clients/client-control-v1.golden.json b/fixtures/clients/client-control-v1.golden.json new file mode 100644 index 00000000000..97bd57cb9ff --- /dev/null +++ b/fixtures/clients/client-control-v1.golden.json @@ -0,0 +1,501 @@ +{ + "_generated": "cargo run -p fractal-client-codegen; source templates/client-control-v1.golden.json.in; fixed PUBLIC TEST key scalar 1, nonce scalar 2, never use for enrollment", + "format": "client-control-v1", + "public_key_sec1_base64url": "BGsX0fLhLEJH-Lzm5WOkQPJ3A32BLeszoPShOUXYmMKWT-NC4v4af5uO5-tKfA-eFivOM1drMV7Oy7ZAaDe_UfU", + "private_scalar_hex_for_tests": "0000000000000000000000000000000000000000000000000000000000000001", + "message_domain_bytes": "smallclaims-claim-fields-v1\nmessage/test\nmessage.sent\nperson/client-control-fixture\ncontent=\"hello\"", + "message_domain_signature_p1363_base64url": "fPJ7GI0DT36KUjgDBLUaw8CJaeJ38hs1pgtI_EdmmXgRe11q295ks9iyv8QCIwcWwVU7WhRTUdQe_JG0MhGy1w", + "vectors": [ + { + "name": "inbox", + "input": { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + "canonical_visible_state": "{\"opened_item\":null,\"route\":\"/inbox\",\"screen\":\"NowRoot\",\"visible_item_ids\":[\"step-run/ask-golden/ask\"]}", + "visible_state_sha256": "4cfea1d08a82607334d7c43e6781b5ae1c2c5066145a5965391e99994a9fd453", + "canonical_bytes": "smallclaims-claim-fields-v1\npurpose=\"client.device-result.v1\"\ncommand_kind=\"phone.inspect\"\ndevice=\"device/test-tui\"\nsession=\"client-session/test-tui\"\ngrant_id=\"client-control-grant/golden\"\nrun_id=\"mission-run/golden\"\ncommand_id=\"device-command/golden-0\"\noutcome=\"completed\"\nerror_code=null\nvisible_state_sha256=\"4cfea1d08a82607334d7c43e6781b5ae1c2c5066145a5965391e99994a9fd453\"", + "signature_p1363_base64url": "fPJ7GI0DT36KUjgDBLUaw8CJaeJ38hs1pgtI_EdmmXiuu76h6230PGyTZfFujp1lxwC70zSTNzoxlic27FPzmg", + "negative_bindings": [ + { + "purpose": "message.send", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/other", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/other", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/other", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.message.read", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/other", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/other", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "failed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": "tampered", + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/tampered", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + } + ] + }, + { + "name": "unicode-opened-item", + "input": { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + "canonical_visible_state": "{\"opened_item\":{\"body\":\"Line 1\\n\\t\\\"\\\\\\u0000\\u001f<>/

\",\"id\":\"step-run/ask-golden/ask\",\"title\":\"Seed é 😀\"},\"route\":\"/attention/step-run/ask-golden/ask\",\"screen\":\"Attention\",\"visible_item_ids\":[\"step-run/ask-golden/ask\",\"message/é😀\"]}", + "visible_state_sha256": "2203b99e4166cda8d04df74ed0c00043490699105058540edb39b86dbf094db3", + "canonical_bytes": "smallclaims-claim-fields-v1\npurpose=\"client.device-result.v1\"\ncommand_kind=\"phone.navigate\"\ndevice=\"device/test-tui\"\nsession=\"client-session/test-tui\"\ngrant_id=\"client-control-grant/golden\"\nrun_id=\"mission-run/golden\"\ncommand_id=\"device-command/golden-1\"\noutcome=\"completed\"\nerror_code=null\nvisible_state_sha256=\"2203b99e4166cda8d04df74ed0c00043490699105058540edb39b86dbf094db3\"", + "signature_p1363_base64url": "fPJ7GI0DT36KUjgDBLUaw8CJaeJ38hs1pgtI_EdmmXi45Ckt8qvn0tLGFBvQ7AEZ-86R5CQtnzYJQ5kea6paXQ", + "negative_bindings": [ + { + "purpose": "message.send", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/other", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/other", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/other", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.message.read", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/other", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/other", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "failed", + "error_code": null, + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": "tampered", + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/tampered", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + } + ] + } + ] +} diff --git a/release-notes/client-control-canonical-v1.json b/release-notes/client-control-canonical-v1.json new file mode 100644 index 00000000000..70114afc2b1 --- /dev/null +++ b/release-notes/client-control-canonical-v1.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Generate one purpose-specific canonical client-control result format for Rust, TypeScript and Swift", + "replay": {"status":"none","detail":"Canonical helpers and wire models do not change store replay or migrate existing claims."}, + "schema": {"status":"changed","detail":"Adds closed visible-state, signed-result binding and per-run control-grant wire models. Granted commands carry grant/run metadata; result requests optionally carry a binding and existing DeviceSignature envelope."}, + "rules": {"status":"changed","detail":"The client.device-result.v1 domain binds command kind, enrolled device and pairing session, grant, run, command id, outcome, error and RFC 8785 visible-state digest. It is never message.send authority and requires no control.messages scope."}, + "compatibility": {"status":"changed","detail":"The existing phone.inspect and phone.navigate verb names are shared by all test-client kinds. Legacy ungranted request/result shapes remain decodable. All clients share fixed-key golden vectors and cross-domain rejection tests."}, + "downtime": {"status":"none","detail":"Source-only contract generation requires no live setup, enrollment, daemon or pairing changes."}, + "manual": {"status":"none","detail":"Regenerate checked-in canonical outputs with cargo run -p fractal-client-codegen. Fixture key scalar and signing nonce are public test material, never production identities."}, + "recovery": {"status":"none","detail":"No persisted data or runtime state is changed by canonical source generation."} +} diff --git a/release-notes/client-control-lifecycle-schema.json b/release-notes/client-control-lifecycle-schema.json new file mode 100644 index 00000000000..b76c9767a4e --- /dev/null +++ b/release-notes/client-control-lifecycle-schema.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Generate typed session-scoped client-control grant lifecycle resources and injectable TypeScript SHA-256", + "replay": {"status":"none","detail":"Wire schema and client decoder sources only; this fragment does not change claim replay."}, + "schema": {"status":"changed","detail":"Adds client-control-grant-lifecycle Resource with grant/run/device/session/item binding, started/ended/revoked state, reason and monotonic sequence on existing commands snapshot/changes frames."}, + "rules": {"status":"changed","detail":"Lifecycle rows drive indicators and termination, not command policy. Decoders reject sequence zero and values above 2^53-1 before ordering checks. Bound item IDs must match; stale valid started rows are ignored; terminal grants cannot reactivate; commands without active lifecycle state fail closed."}, + "compatibility": {"status":"changed","detail":"Rust, Swift and TypeScript decode generated lifecycle resources; older clients preserve unknown resources. Scenario folding ignores lifecycle metadata. TypeScript canonical helpers accept generic injected SHA-256 bytes for Hermes; the default WebCrypto adapter owns its input buffer and exposes ClientResultCanonicalizationError with invalid-unicode code. Canonical bytes remain unchanged."}, + "downtime": {"status":"none","detail":"No live setup, enrollment or daemon operation is performed by these source changes."}, + "manual": {"status":"none","detail":"Integrate source layers before one cargo run -p fractal-client-codegen. Shared lifecycle resources, frames and fail-closed traces are checked by client tests."}, + "recovery": {"status":"none","detail":"No stored data migration is introduced by generated lifecycle decoder source."} +}