Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions appleApp/FTW/Access/AccessView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,11 @@ struct AccessView: View {
}

/// Removing is one tap: no Face ID and no question, since it is always
/// safe.
/// safe. The row leaves in the swipe's own change; the box hears of it
/// after, and the row comes back if the box refuses.
private func remove(_ row: AgentRow) -> some View {
Button(role: .destructive) {
Task { await access.revoke(row) }
withAnimation(Theme.spring) { access.remove(row) }
} label: {
Label(AccessWords.text("Remove access"), systemImage: "xmark.circle")
}
Expand Down
7 changes: 4 additions & 3 deletions appleApp/FTW/Access/GrantView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,10 @@ struct GrantView: View {

Section {
Button(role: .destructive) {
Task {
if await access.revoke(row) { dismiss() }
}
// Gone from the list at once; back there with the
// box's reason if it refuses.
access.remove(row)
dismiss()
} label: {
Label(AccessWords.text("Remove access"), systemImage: "xmark.circle")
}
Expand Down
22 changes: 22 additions & 0 deletions appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,31 @@ public final class SimulatedAccess {
}
}

/// The tester's home on 17 October 2026: Grok took four tries, so three
/// grants never called and one did. On top of `seed()`.
public func seedGrokTries() {
let now = Int64(nowMs())
let day: Int64 = 86_400_000
for i in 0..<4 {
let key = Bytes(repeating: UInt8(0x31 + i), count: 32)
rows.append(AgentRow(id: String(Base64url.encode(key).prefix(8)), key: key, role: Contract.roleAgent,
scopes: ["ftw.health.read", "ftw.live.read", "ftw.plan.read"], ops: [],
caps: Caps(perMin: 6), expiresAtMs: now + 30 * day - Int64(i) * 60_000,
principal: Principal(client: "Grok", clientId: "https://grok.com/oauth/mcp-client"),
approvedBy: "app:\(signedFor.deviceID)", addedAtMs: now - Int64(4 - i) * 6 * 60_000,
lastSeenMs: i == 3 ? now - 2 * 60_000 : 0, epoch: 1))
}
}

// MARK: Messages

/// For a test: act on the next message but lose its answer, as a
/// dropped connection would.
public var loseNextAnswer = false
/// For a test: rows whose removal the box refuses, as a busy box does.
public var refuseRemoving: Set<String> = []
/// How many times the phone read the list.
public private(set) var lists = 0

func receive(_ env: Envelope, reply given: @escaping (Envelope) -> Void) {
var reply = given
Expand All @@ -87,6 +107,7 @@ public final class SimulatedAccess {
let now = Int64(nowMs())
switch env.t {
case AccessWire.msgList:
lists += 1
var list: [(String, CBOR)] = [
("agents", .array(rows.map(Self.cbor))),
("requests", .array(requests.map(Self.cbor))),
Expand Down Expand Up @@ -163,6 +184,7 @@ public final class SimulatedAccess {
ok(.map([("agent", Self.cbor(rows[i]))]))
case AccessWire.msgRevoke:
guard let id = body["id"]?.string, rows.contains(where: { $0.id == id }) else { return refuse("E_NOT_FOUND", []) }
if refuseRemoving.contains(id) { return refuse("E_UNAVAILABLE", []) }
rows.removeAll { $0.id == id }
requests.removeAll { $0.agent == id }
log("app:\(signedFor.deviceID)", what: AccessWire.msgRevoke, target: id, result: "ok")
Expand Down
17 changes: 16 additions & 1 deletion appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ public final class SimulatedBox {
public private(set) var stepUpsSeen = 0
/// Its assistants, support grants, requests and audit log.
public private(set) lazy var access = SimulatedAccess { [scheduler] in scheduler.nowMs }
/// How long an answer about agents waits to leave: a box behind FTW
/// Cloud, far from the phone.
public var accessDelayMs: Double = 0

public nonisolated static let modes: [ModeInfo] = [
ModeInfo(key: "planner_passive_arbitrage", label: "Passive arbitrage", tooltip: "Charge from the cheapest available source (PV when sunny, grid during cheap hours). Never exports from battery.", tier: "primary"),
Expand Down Expand Up @@ -195,7 +198,14 @@ public final class SimulatedBox {
case "cmd": onCommand(body)
case AccessWire.msgList, AccessWire.msgAudit, AccessWire.msgKeySet, AccessWire.msgAdd, AccessWire.msgWiden,
AccessWire.msgRevoke, AccessWire.msgConfirm:
access.receive(env) { [weak self] in self?.sendBulk($0) }
access.receive(env) { [weak self] reply in
guard let self else { return }
if self.accessDelayMs > 0 {
self.scheduler.after(self.accessDelayMs) { [weak self] in self?.sendBulk(reply) }
} else {
self.sendBulk(reply)
}
}
default: break
}
}
Expand Down Expand Up @@ -920,6 +930,11 @@ extension SimulatedBox {
// For UI tests that look while a command waits: `-FTWDemoResultDelayMs
// 2500` answers later than XCTest's wait for the app to settle.
if UserDefaults.standard.object(forKey: "FTWDemoResultDelayMs") != nil { box.resultDelayMs = UserDefaults.standard.double(forKey: "FTWDemoResultDelayMs") }
// For the removal test: `-FTWDemoAccess grok` adds the four Grok
// grants a tester was left with, and `-FTWDemoAccessDelayMs 600`
// answers about agents as late as a box behind FTW Cloud.
if UserDefaults.standard.string(forKey: "FTWDemoAccess") == "grok" { box.access.seedGrokTries() }
if UserDefaults.standard.object(forKey: "FTWDemoAccessDelayMs") != nil { box.accessDelayMs = UserDefaults.standard.double(forKey: "FTWDemoAccessDelayMs") }
// For screenshots: `-FTWDemoCar unplugged` and the like.
if let name = UserDefaults.standard.string(forKey: "FTWDemoCar"), let car = DemoCar(rawValue: name) {
car.apply(to: box.house, nowMs: scheduler.nowMs)
Expand Down
78 changes: 59 additions & 19 deletions appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,16 @@ public final class AccessModel {
public private(set) var keysKnown = false
/// The row or request a change is on its way for.
public private(set) var working: String?
/// Rows the owner removed whose answer has not come back. They leave
/// the lists at once, and come back only if the box refuses.
public private(set) var removing: Set<String> = []
/// Rows the box has said are gone. An answer to `agent.list` sent
/// before the box removed one still holds it, and must not bring it
/// back.
@ObservationIgnored private var removed: Set<String> = []
/// Why each failed removal failed, kept until the read after the last
/// removal so that read does not wipe it.
@ObservationIgnored private var refused: [String: String] = [:]
/// What went wrong, in words that say what happens now.
public private(set) var help: String?

Expand Down Expand Up @@ -238,9 +248,9 @@ public final class AccessModel {
site.heardFromBox && site.canConfigure && site.session.caps.contains(Contract.capAccessAgents)
}

public var assistants: [AgentRow] { (list?.agents ?? []).filter { !$0.isSupport } }
public var support: [AgentRow] { (list?.agents ?? []).filter(\.isSupport) }
public var pending: [AgentRequest] { (list?.requests ?? []).filter(\.pending) }
public var assistants: [AgentRow] { (list?.agents ?? []).filter { !$0.isSupport && !removing.contains($0.id) } }
public var support: [AgentRow] { (list?.agents ?? []).filter { $0.isSupport && !removing.contains($0.id) } }
public var pending: [AgentRequest] { (list?.requests ?? []).filter { $0.pending && !removing.contains($0.agent) } }

/// The box holds an approval key for this phone.
public var boxHasKey: Bool { list?.keys.contains { $0.deviceId == signedFor.deviceID } ?? false }
Expand All @@ -264,7 +274,7 @@ public final class AccessModel {
public func refresh() async {
guard available else { return }
do {
list = AgentList(try await site.ask(AccessWire.msgList, .emptyMap))
list = withoutRemoved(AgentList(try await site.ask(AccessWire.msgList, .emptyMap)))
keysKnown = true
help = nil
} catch {
Expand Down Expand Up @@ -335,29 +345,59 @@ public final class AccessModel {
return changed != nil
}

/// Remove access at once. No Face ID: removing is always safe.
/// Remove access at once: the row and its requests leave the lists in
/// this change, the one the owner's swipe animates, and the box hears
/// of it after. No Face ID: removing is always safe.
public func remove(_ row: AgentRow) {
guard removing.insert(row.id).inserted else { return }
Task { await sendRevoke(row) }
}

/// `remove`, waiting for the box. True when the row is gone; false when
/// the box refused and the row is back, or another removal of it is on
/// its way.
@discardableResult
public func revoke(_ row: AgentRow) async -> Bool {
working = row.id
defer { working = nil }
guard removing.insert(row.id).inserted else { return false }
return await sendRevoke(row)
}

/// Each removal goes to the box at once and on its own. Its answer
/// changes nothing the owner sees unless the box refused. The box is
/// read once, after the last answer, rather than once per removal.
private func sendRevoke(_ row: AgentRow) async -> Bool {
var gone = true
do {
_ = try await site.ask(AccessWire.msgRevoke, .map([("id", .text(row.id))]))
if var l = list {
l.agents.removeAll { $0.id == row.id }
l.requests.removeAll { $0.agent == row.id }
list = l
}
help = nil
await refresh()
return true
} catch let r as BoxRefusal where r.detail.code == "E_NOT_FOUND" {
// Already gone, whoever removed it.
await refresh()
return true
} catch {
help = words(error)
return false
gone = false
refused[row.id] = words(error)
help = refused[row.id]
}
if gone {
removed.insert(row.id)
list = list.map(withoutRemoved)
}
removing.remove(row.id)
guard removing.isEmpty else { return gone }
await refresh()
// A failure stands while its row does. A removal whose answer was
// lost may still have gone through, and then the read says so.
if let why = refused.first(where: { id, _ in self.row(id) != nil })?.value { help = why }
refused = [:]
return gone
}

/// The box's list without the rows it has already said are gone, and
/// without their requests.
private func withoutRemoved(_ l: AgentList) -> AgentList {
guard !removed.isEmpty else { return l }
var l = l
l.agents.removeAll { removed.contains($0.id) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop hiding re-granted assistants forever

Once a revoke succeeds, its ID is added to removed, but that tombstone is never cleared. If the same assistant key/ID is granted again while this AccessModel remains alive鈥攚hether from this phone or another owner phone鈥攖he box accepts the new grant, yet every subsequent agent.list response deletes its row here. The assistant then has active access that this UI neither displays nor lets the owner revoke; retire tombstones after an authoritative post-revoke list confirms the old row is absent, or otherwise distinguish grant incarnations.

Useful? React with 馃憤聽/ 馃憥.

l.requests.removeAll { removed.contains($0.agent) }
return l
}

// MARK: Requests
Expand Down
116 changes: 116 additions & 0 deletions appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,122 @@ import FTWDemo
#expect(w.access.pending.isEmpty)
}

/// What the owner sees in Access: the assistants and the requests.
struct Seen: Equatable {
var assistants: [String]
var pending: [String]
}

/// The home a tester was left with on 17 October 2026: Claude with a
/// request, and four Grok grants from four tries.
func grokWorld() async -> World {
let w = await world()
w.box.access.seedGrokTries()
await w.during { await w.access.refresh() }
return w
}

/// Run the clock and note each change the owner would see.
func watch(_ w: World, _ ms: Double = 2_000) async -> [Seen] {
var seen: [Seen] = []
var left = ms
while left > 0 {
for _ in 0..<20 { await Task.yield() }
let now = Seen(assistants: w.access.assistants.map(\.id), pending: w.access.pending.map(\.reqId))
if seen.last != now { seen.append(now) }
w.scheduler.advance(10)
left -= 10
}
return seen
}

/// Five swipes before the box has answered one: each row and its
/// request leave at once, nothing comes back while the answers arrive,
/// and the box is read once, after the last.
@Test func severalRemovalsLeaveOneConsistentList() async throws {
let w = await grokWorld()
let rows = w.access.assistants
#expect(rows.count == 5)
#expect(w.access.pending.count == 1)
let reads = w.box.access.lists
for row in rows { w.access.remove(row) }
let gone = Seen(assistants: [], pending: [])
#expect(Seen(assistants: w.access.assistants.map(\.id), pending: w.access.pending.map(\.reqId)) == gone)
let seen = await watch(w)
#expect(seen == [gone])
#expect(w.access.removing.isEmpty)
#expect(w.access.list?.agents.contains { !$0.isSupport } == false)
#expect(w.access.list?.requests.isEmpty == true)
#expect(w.box.access.rows.allSatisfy { $0.isSupport })
#expect(w.box.access.lists == reads + 1)
#expect(w.keyring.unlocks == 0)
}

/// The same through `revoke`, all at once: each says it is gone.
@Test func concurrentRevokesAllSucceed() async throws {
let w = await grokWorld()
let rows = w.access.assistants
let results = await w.during {
let tasks = rows.map { row in Task { @MainActor in await w.access.revoke(row) } }
var out: [Bool] = []
for task in tasks { out.append(await task.value) }
return out
}
#expect(results == Array(repeating: true, count: rows.count))
#expect(w.access.assistants.isEmpty)
#expect(w.access.pending.isEmpty)
#expect(w.box.access.rows.allSatisfy { $0.isSupport })
// A second removal of a row on its way says nothing went.
let w2 = await grokWorld()
let one = try #require(w2.access.assistants.last)
w2.access.remove(one)
#expect(await w2.access.revoke(one) == false)
}

/// The box refuses one of several: that row comes back once, with the
/// box's reason, and the others stay gone.
@Test func aRefusedRemovalComesBackWithTheReason() async throws {
let w = await grokWorld()
let grok = w.access.assistants.filter { $0.principal.client == "Grok" }
#expect(grok.count == 4)
let kept = grok[1]
w.box.access.refuseRemoving = [kept.id]
for row in grok { w.access.remove(row) }
let seen = await watch(w)
let claude = "q7Xn2vKd"
#expect(seen.map(\.assistants) == [[claude], [claude, kept.id]])
#expect(w.access.help == AccessWords.text("Your box is busy. Try again in a moment."))
#expect(w.box.access.rows.contains { $0.id == kept.id })
#expect(w.access.pending.count == 1)
}

/// The box removes a row but its answer is lost: the row comes back
/// when the wait runs out, then goes with the read after it, and no
/// failure is left on screen for a removal that went through.
@Test func aLostAnswerEndsWithTheBoxsWord() async throws {
let w = await grokWorld()
let row = try #require(w.access.assistants.last)
w.box.access.loseNextAnswer = true
let task = Task { await w.access.revoke(row) }
await w.run(10_000)
#expect(await task.value == false)
#expect(!w.box.access.rows.contains { $0.id == row.id })
#expect(!w.access.assistants.contains { $0.id == row.id })
#expect(w.access.help == nil)
}

/// A row the box no longer has, as a page left open shows it, is as
/// good as removed.
@Test func removingWhatIsAlreadyGoneIsDone() async throws {
let w = await grokWorld()
let row = try #require(w.access.assistants.last)
await w.during { _ = await w.access.revoke(row) }
let again = await w.during { await w.access.revoke(row) }
#expect(again)
#expect(w.access.help == nil)
#expect(!w.access.assistants.contains { $0.id == row.id })
}

/// Approving signs the request, the agent, the op and the hash of the
/// args exactly as received; declining is one tap.
@Test func aRequestIsApprovedOverWhatWasShown() async throws {
Expand Down
Loading
Loading