Skip to content

Commit d99d59f

Browse files
roninjin10codex
andcommitted
feat(flags): 0155 — wire remote_sandbox_enabled server flag into iOS + macOS as production kill switch
Co-Authored-By: Codex <codex@openai.com>
1 parent a52f61c commit d99d59f

12 files changed

Lines changed: 1055 additions & 104 deletions

File tree

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import Combine
2+
import Foundation
3+
4+
public struct FeatureFlagsSnapshot: Equatable, Sendable {
5+
public static let empty = FeatureFlagsSnapshot(flags: [:])
6+
7+
public let flags: [String: Bool]
8+
9+
public init(flags: [String: Bool]) {
10+
self.flags = flags
11+
}
12+
13+
public var isRemoteSandboxEnabled: Bool {
14+
flag(named: "remote_sandbox_enabled")
15+
}
16+
17+
public var isApprovalsFlowEnabled: Bool {
18+
flag(named: "approvals_flow_enabled")
19+
}
20+
21+
public var isElectricClientEnabled: Bool {
22+
flag(named: "electric_client_enabled")
23+
}
24+
25+
public var isDevtoolsSnapshotEnabled: Bool {
26+
flag(named: "devtools_snapshot_enabled")
27+
}
28+
29+
public var isRunShapeEnabled: Bool {
30+
flag(named: "run_shape_enabled")
31+
}
32+
33+
public func flag(named name: String) -> Bool {
34+
flags[name] == true
35+
}
36+
}
37+
38+
public enum FeatureFlagsError: Error, Equatable {
39+
case notSignedIn
40+
case unauthorized
41+
case badStatus(Int, String)
42+
case invalidResponse
43+
case transport(String)
44+
}
45+
46+
@MainActor
47+
public final class FeatureFlagsClient: ObservableObject {
48+
public typealias BearerProvider = @Sendable () throws -> String?
49+
public typealias MockResponseProvider = @Sendable () async throws -> FeatureFlagsSnapshot?
50+
51+
@Published public private(set) var snapshot: FeatureFlagsSnapshot
52+
@Published public private(set) var isRefreshing: Bool = false
53+
@Published public private(set) var lastRefreshAt: Date?
54+
@Published public private(set) var lastErrorDescription: String?
55+
56+
public let ttl: TimeInterval
57+
58+
private let baseURL: URL
59+
private let transport: HTTPTransport
60+
private let bearerProvider: BearerProvider
61+
private let now: @Sendable () -> Date
62+
private var cachedAt: Date?
63+
private var inFlightRefresh: Task<FeatureFlagsSnapshot, Error>?
64+
private var mockResponseProvider: MockResponseProvider?
65+
66+
public init(
67+
baseURL: URL,
68+
transport: HTTPTransport = URLSessionHTTPTransport(),
69+
bearerProvider: @escaping BearerProvider,
70+
ttl: TimeInterval = 60,
71+
now: @escaping @Sendable () -> Date = { Date() },
72+
initialSnapshot: FeatureFlagsSnapshot = .empty,
73+
mockResponseProvider: MockResponseProvider? = nil
74+
) {
75+
self.baseURL = baseURL
76+
self.transport = transport
77+
self.bearerProvider = bearerProvider
78+
self.ttl = ttl
79+
self.now = now
80+
self.snapshot = initialSnapshot
81+
self.mockResponseProvider = mockResponseProvider
82+
}
83+
84+
public var isRemoteSandboxEnabled: Bool { snapshot.isRemoteSandboxEnabled }
85+
public var isApprovalsFlowEnabled: Bool { snapshot.isApprovalsFlowEnabled }
86+
public var isElectricClientEnabled: Bool { snapshot.isElectricClientEnabled }
87+
public var isDevtoolsSnapshotEnabled: Bool { snapshot.isDevtoolsSnapshotEnabled }
88+
public var isRunShapeEnabled: Bool { snapshot.isRunShapeEnabled }
89+
90+
public func setMockResponseProvider(_ provider: MockResponseProvider?) {
91+
mockResponseProvider = provider
92+
}
93+
94+
@discardableResult
95+
public func refresh(force: Bool = false) async throws -> FeatureFlagsSnapshot {
96+
if !force,
97+
let cachedAt,
98+
now().timeIntervalSince(cachedAt) < ttl {
99+
return snapshot
100+
}
101+
102+
if let inFlightRefresh {
103+
return try await inFlightRefresh.value
104+
}
105+
106+
isRefreshing = true
107+
let task = Task<FeatureFlagsSnapshot, Error> { [baseURL, transport, bearerProvider, mockResponseProvider] in
108+
if let mockResponseProvider,
109+
let mocked = try await mockResponseProvider() {
110+
return mocked
111+
}
112+
113+
guard let token = try bearerProvider(), !token.isEmpty else {
114+
throw FeatureFlagsError.notSignedIn
115+
}
116+
117+
var request = URLRequest(url: baseURL.appendingPathComponent("api/feature-flags"))
118+
request.httpMethod = "GET"
119+
request.setValue("application/json", forHTTPHeaderField: "Accept")
120+
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
121+
122+
do {
123+
let (data, status, _) = try await transport.send(request)
124+
guard (200...299).contains(status) else {
125+
if status == 401 {
126+
throw FeatureFlagsError.unauthorized
127+
}
128+
let snippet = String(data: data.prefix(256), encoding: .utf8) ?? ""
129+
throw FeatureFlagsError.badStatus(status, snippet)
130+
}
131+
return try Self.decodeSnapshot(from: data)
132+
} catch let error as FeatureFlagsError {
133+
throw error
134+
} catch let error as OAuth2Error {
135+
switch error {
136+
case .unauthorized:
137+
throw FeatureFlagsError.unauthorized
138+
case .transport(let message):
139+
throw FeatureFlagsError.transport(message)
140+
case .badStatus(let status, let snippet):
141+
throw FeatureFlagsError.badStatus(status, snippet)
142+
default:
143+
throw FeatureFlagsError.transport(AuthViewModel.describe(error))
144+
}
145+
} catch {
146+
throw FeatureFlagsError.transport(error.localizedDescription)
147+
}
148+
}
149+
150+
inFlightRefresh = task
151+
152+
do {
153+
let refreshed = try await task.value
154+
apply(snapshot: refreshed, at: now())
155+
inFlightRefresh = nil
156+
isRefreshing = false
157+
lastErrorDescription = nil
158+
return refreshed
159+
} catch {
160+
inFlightRefresh = nil
161+
isRefreshing = false
162+
lastErrorDescription = Self.describe(error)
163+
throw error
164+
}
165+
}
166+
167+
private func apply(snapshot: FeatureFlagsSnapshot, at date: Date) {
168+
self.snapshot = snapshot
169+
self.cachedAt = date
170+
self.lastRefreshAt = date
171+
}
172+
173+
private static func describe(_ error: Error) -> String {
174+
switch error {
175+
case let featureFlagsError as FeatureFlagsError:
176+
switch featureFlagsError {
177+
case .notSignedIn:
178+
return "Not signed in."
179+
case .unauthorized:
180+
return "Unauthorized."
181+
case .badStatus(let status, _):
182+
return "Server returned status \(status)."
183+
case .invalidResponse:
184+
return "Server returned an unexpected response."
185+
case .transport(let message):
186+
return message
187+
}
188+
default:
189+
return error.localizedDescription
190+
}
191+
}
192+
193+
private static func decodeSnapshot(from data: Data) throws -> FeatureFlagsSnapshot {
194+
if let envelope = try? JSONDecoder().decode(FeatureFlagsEnvelope.self, from: data) {
195+
return FeatureFlagsSnapshot(flags: envelope.flags)
196+
}
197+
if let direct = try? JSONDecoder().decode([String: Bool].self, from: data) {
198+
return FeatureFlagsSnapshot(flags: direct)
199+
}
200+
throw FeatureFlagsError.invalidResponse
201+
}
202+
}
203+
204+
private struct FeatureFlagsEnvelope: Decodable {
205+
let flags: [String: Bool]
206+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import XCTest
2+
@testable import SmithersAuth
3+
4+
@MainActor
5+
final class FeatureFlagsClientTests: XCTestCase {
6+
func test_refresh_fetches_flags_with_bearer_and_decodes_envelope() async throws {
7+
let transport = MockHTTPTransport()
8+
transport.responses = [
9+
.json(payload: [
10+
"flags": [
11+
"remote_sandbox_enabled": false,
12+
"approvals_flow_enabled": true,
13+
"run_shape_enabled": true,
14+
],
15+
]),
16+
]
17+
18+
let client = FeatureFlagsClient(
19+
baseURL: URL(string: "https://plue.test")!,
20+
transport: transport,
21+
bearerProvider: { "FAKE_BEARER" }
22+
)
23+
24+
let snapshot = try await client.refresh(force: true)
25+
26+
XCTAssertFalse(snapshot.isRemoteSandboxEnabled)
27+
XCTAssertTrue(snapshot.isApprovalsFlowEnabled)
28+
XCTAssertTrue(snapshot.isRunShapeEnabled)
29+
XCTAssertEqual(transport.recorded.count, 1)
30+
XCTAssertEqual(transport.recorded[0].method, "GET")
31+
XCTAssertEqual(
32+
transport.recorded[0].url.absoluteString,
33+
"https://plue.test/api/feature-flags"
34+
)
35+
}
36+
37+
func test_refresh_uses_ttl_cache_until_expired() async throws {
38+
let transport = MockHTTPTransport()
39+
transport.responses = [
40+
.json(payload: ["flags": ["remote_sandbox_enabled": true]]),
41+
.json(payload: ["flags": ["remote_sandbox_enabled": false]]),
42+
]
43+
let clock = TestClock(now: Date(timeIntervalSince1970: 1_700_000_000))
44+
let client = FeatureFlagsClient(
45+
baseURL: URL(string: "https://plue.test")!,
46+
transport: transport,
47+
bearerProvider: { "FAKE_BEARER" },
48+
ttl: 60,
49+
now: { clock.now }
50+
)
51+
52+
let first = try await client.refresh(force: true)
53+
let cached = try await client.refresh()
54+
clock.now.addTimeInterval(61)
55+
let refreshed = try await client.refresh()
56+
57+
XCTAssertTrue(first.isRemoteSandboxEnabled)
58+
XCTAssertTrue(cached.isRemoteSandboxEnabled)
59+
XCTAssertFalse(refreshed.isRemoteSandboxEnabled)
60+
XCTAssertEqual(transport.recorded.count, 2)
61+
}
62+
63+
func test_mock_response_provider_supports_flag_flip_on_next_refresh() async throws {
64+
let box = FeatureFlagBox(remoteEnabled: true)
65+
let client = FeatureFlagsClient(
66+
baseURL: URL(string: "https://plue.test")!,
67+
bearerProvider: { nil },
68+
mockResponseProvider: {
69+
FeatureFlagsSnapshot(flags: [
70+
"remote_sandbox_enabled": box.remoteEnabled,
71+
"approvals_flow_enabled": true,
72+
])
73+
}
74+
)
75+
76+
let enabled = try await client.refresh(force: true)
77+
box.remoteEnabled = false
78+
let disabled = try await client.refresh(force: true)
79+
80+
XCTAssertTrue(enabled.isRemoteSandboxEnabled)
81+
XCTAssertFalse(disabled.isRemoteSandboxEnabled)
82+
XCTAssertFalse(client.isRemoteSandboxEnabled)
83+
}
84+
}
85+
86+
private final class TestClock: @unchecked Sendable {
87+
var now: Date
88+
89+
init(now: Date) {
90+
self.now = now
91+
}
92+
}
93+
94+
private final class FeatureFlagBox: @unchecked Sendable {
95+
var remoteEnabled: Bool
96+
97+
init(remoteEnabled: Bool) {
98+
self.remoteEnabled = remoteEnabled
99+
}
100+
}

SidebarView.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ struct SidebarView: View {
1616
// the sidebar reacts to sign-in/out without plumbing bindings through
1717
// ContentView (which 0122 just refactored and the ticket asks us to keep
1818
// narrow).
19-
@ObservedObject private var remoteMode: RemoteModeController = .shared
19+
@ObservedObject private var remoteMode: RemoteModeController
2020
#endif
2121
@Binding var destination: NavDestination
2222
@Binding private var developerDebugPanelVisible: Bool
@@ -34,9 +34,11 @@ struct SidebarView: View {
3434
return (info?["CFBundleShortVersionString"] as? String) ?? "0.0.1"
3535
}()
3636

37+
@MainActor
3738
init(
3839
store: SessionStore,
3940
destination: Binding<NavDestination>,
41+
remoteMode: RemoteModeController? = nil,
4042
developerDebugPanelVisible: Binding<Bool> = .constant(false),
4143
developerDebugAvailable: Bool = DeveloperDebugMode.isEnabled,
4244
onOpenNewTabPicker: @escaping () -> Void = {},
@@ -48,6 +50,9 @@ struct SidebarView: View {
4850
self.developerDebugAvailable = developerDebugAvailable
4951
self.onOpenNewTabPicker = onOpenNewTabPicker
5052
self.versionProvider = versionProvider
53+
#if os(macOS)
54+
self._remoteMode = ObservedObject(wrappedValue: remoteMode ?? .shared)
55+
#endif
5156
}
5257

5358
var body: some View {

0 commit comments

Comments
 (0)