File size: 15,298 Bytes
b8370f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
import Foundation
import SQLite3
import Testing
@testable import OpenClawKit

struct ExecApprovalsSQLiteStoreTests {
    @Test
    func `missing singleton row returns nil`() throws {
        try self.withStateDirectory { stateDirectoryURL in
            let record = try ExecApprovalsSQLiteStore.read(stateDirectoryURL: stateDirectoryURL)
            #expect(record == nil)
        }
    }

    @Test
    func `document round trips and update replaces singleton`() throws {
        try self.withStateDirectory { stateDirectoryURL in
            let original = Self.document(token: "first-token", agentCount: 1)
            try ExecApprovalsSQLiteStore.write(original, stateDirectoryURL: stateDirectoryURL)
            #expect(try ExecApprovalsSQLiteStore.read(
                stateDirectoryURL: stateDirectoryURL)?.document == original)

            let updated = Self.document(token: "second-token", agentCount: 2)
            try ExecApprovalsSQLiteStore.write(updated, stateDirectoryURL: stateDirectoryURL)
            let stored = try ExecApprovalsSQLiteStore.read(stateDirectoryURL: stateDirectoryURL)
            let record = try #require(stored)
            #expect(record.document == updated)
            #expect(record.rawJSON.contains("\"version\" : 1"))
            #expect(record.rawJSON.contains("\"socket\""))
            #expect(record.rawJSON.contains("\"defaults\""))
            #expect(record.rawJSON.contains("\"agents\""))
        }
    }

    @Test
    func `write derives every projection from authoritative document`() throws {
        try self.withStateDirectory { stateDirectoryURL in
            let document = ExecApprovalsDocument(
                version: 1,
                socket: ExecApprovalsSocketDocument(path: "/tmp/openclaw.sock", token: "secret"),
                defaults: ExecApprovalsDefaultsDocument(
                    security: .allowlist,
                    ask: .onMiss,
                    askFallback: .deny,
                    autoAllowSkills: true),
                agents: [
                    "main": ExecApprovalsAgentDocument(allowlist: [
                        ExecApprovalsAllowlistEntry(id: "one", pattern: "/usr/bin/git"),
                        ExecApprovalsAllowlistEntry(id: "two", pattern: "/usr/bin/swift"),
                    ]),
                    "worker": ExecApprovalsAgentDocument(allowlist: [
                        ExecApprovalsAllowlistEntry(id: "three", pattern: "/usr/bin/node"),
                    ]),
                ])
            try ExecApprovalsSQLiteStore.write(
                document,
                stateDirectoryURL: stateDirectoryURL,
                updatedAtMilliseconds: 123_456)

            let projection = try Self.readProjection(
                ExecApprovalsSQLiteStore.databaseURL(stateDirectoryURL: stateDirectoryURL))
            #expect(projection == Projection(
                socketPath: "/tmp/openclaw.sock",
                hasSocketToken: 1,
                defaultSecurity: "allowlist",
                defaultAsk: "on-miss",
                defaultAskFallback: "deny",
                autoAllowSkills: 1,
                agentCount: 2,
                allowlistCount: 3,
                updatedAtMilliseconds: 123_456))
        }
    }

    @Test
    func `deletion journal fences only the affected agent`() throws {
        try self.withStateDirectory { stateDirectoryURL in
            var original = Self.document(token: "original", agentCount: 1)
            let originalAgent = original.agents?.removeValue(forKey: "agent-0")
            original.agents?["Agent A"] = originalAgent
            try ExecApprovalsSQLiteStore.write(original, stateDirectoryURL: stateDirectoryURL)
            let databaseURL = ExecApprovalsSQLiteStore.databaseURL(
                stateDirectoryURL: stateDirectoryURL)
            try Self.execute(databaseURL, """
            CREATE TABLE schema_meta (
              meta_key TEXT NOT NULL PRIMARY KEY,
              role TEXT NOT NULL,
              schema_version INTEGER NOT NULL,
              agent_id TEXT,
              app_version TEXT,
              created_at INTEGER NOT NULL,
              updated_at INTEGER NOT NULL
            ) STRICT;
            INSERT INTO schema_meta (
              meta_key, role, schema_version, agent_id,
              app_version, created_at, updated_at
            ) VALUES ('primary', 'global', 6, NULL, NULL, 1, 1);
            CREATE TABLE agent_deletion_journal (
              agent_id TEXT PRIMARY KEY,
              operation_id TEXT NOT NULL DEFAULT '',
              agent_dir TEXT NOT NULL,
              workspace_dir TEXT NOT NULL,
              sessions_dir TEXT NOT NULL,
              database_paths_json TEXT NOT NULL DEFAULT '[]',
              cleanup_paths_json TEXT NOT NULL DEFAULT '[]',
              created_at INTEGER NOT NULL,
              cleanup_completed INTEGER NOT NULL DEFAULT 0,
              delete_files INTEGER NOT NULL DEFAULT 1
            ) STRICT;
            INSERT INTO agent_deletion_journal (
              agent_id, operation_id, agent_dir, workspace_dir, sessions_dir, created_at
            ) VALUES ('agent-a', 'typescript-deletion', '/agent', '/workspace', '/sessions', 1);
            PRAGMA user_version = 6;
            """)

            var replacement = Self.document(token: "replacement", agentCount: 2)
            let replacementAgent = replacement.agents?.removeValue(forKey: "agent-0")
            replacement.agents?["Agent A"] = replacementAgent
            replacement.agents?["Agent A"]?.security = .deny
            do {
                try ExecApprovalsSQLiteStore.write(
                    replacement,
                    stateDirectoryURL: stateDirectoryURL)
                Issue.record("Expected active agent deletion journal to fence the write")
            } catch {
                #expect(error.localizedDescription.contains("agent deletion is in progress; retry"))
            }
            #expect(try ExecApprovalsSQLiteStore.read(
                stateDirectoryURL: stateDirectoryURL)?.document == original)

            var unrelated = original
            unrelated.socket?.token = "unrelated"
            try ExecApprovalsSQLiteStore.write(unrelated, stateDirectoryURL: stateDirectoryURL)
            #expect(try ExecApprovalsSQLiteStore.read(
                stateDirectoryURL: stateDirectoryURL)?.document == unrelated)

            try Self.execute(databaseURL, "DELETE FROM agent_deletion_journal")
            try ExecApprovalsSQLiteStore.write(
                replacement,
                stateDirectoryURL: stateDirectoryURL)
            #expect(try ExecApprovalsSQLiteStore.read(
                stateDirectoryURL: stateDirectoryURL)?.document == replacement)
        }
    }

    @Test
    func `projections use TypeScript normalization rules`() throws {
        try self.withStateDirectory { stateDirectoryURL in
            let shared = ExecApprovalsAllowlistEntry(id: "shared", pattern: " /USR/BIN/GIT ")
            let document = ExecApprovalsDocument(
                version: 1,
                socket: ExecApprovalsSocketDocument(path: " /tmp/openclaw.sock ", token: "   "),
                agents: [
                    "default": ExecApprovalsAgentDocument(allowlist: [shared]),
                    "main": ExecApprovalsAgentDocument(allowlist: [
                        ExecApprovalsAllowlistEntry(id: "current", pattern: "/usr/bin/git"),
                    ]),
                ])
            try ExecApprovalsSQLiteStore.write(
                document,
                stateDirectoryURL: stateDirectoryURL,
                updatedAtMilliseconds: 42)

            let projection = try Self.readProjection(
                ExecApprovalsSQLiteStore.databaseURL(stateDirectoryURL: stateDirectoryURL))
            #expect(projection.socketPath == "/tmp/openclaw.sock")
            #expect(projection.hasSocketToken == 0)
            #expect(projection.agentCount == 1)
            #expect(projection.allowlistCount == 1)
        }
    }

    @Test
    func `malformed raw json fails closed`() throws {
        try self.withStateDirectory { stateDirectoryURL in
            try ExecApprovalsSQLiteStore.write(
                Self.document(token: "token", agentCount: 1),
                stateDirectoryURL: stateDirectoryURL)
            try Self.execute(
                ExecApprovalsSQLiteStore.databaseURL(stateDirectoryURL: stateDirectoryURL),
                "UPDATE exec_approvals_config SET raw_json = '{\"version\":2}'")

            #expect(throws: (any Error).self) {
                try ExecApprovalsSQLiteStore.read(stateDirectoryURL: stateDirectoryURL)
            }
        }
    }

    @Test
    func `invalid allowlist pattern is rejected before write`() throws {
        try self.withStateDirectory { stateDirectoryURL in
            let invalid = ExecApprovalsDocument(
                version: 1,
                agents: [
                    "main": ExecApprovalsAgentDocument(allowlist: [
                        ExecApprovalsAllowlistEntry(pattern: "   "),
                    ]),
                ])

            #expect(throws: (any Error).self) {
                try ExecApprovalsSQLiteStore.write(
                    invalid,
                    stateDirectoryURL: stateDirectoryURL)
            }
            let record = try ExecApprovalsSQLiteStore.read(stateDirectoryURL: stateDirectoryURL)
            #expect(record == nil)
        }
    }

    @Test(arguments: ["", ".doctor-importing"])
    func `legacy source or Doctor claim refuses SQLite access`(suffix: String) throws {
        try self.withStateDirectory { stateDirectoryURL in
            let legacyURL = stateDirectoryURL.appendingPathComponent(
                "exec-approvals.json\(suffix)",
                isDirectory: false)
            try Data("{\"version\":1,\"agents\":{}}".utf8).write(to: legacyURL)

            do {
                _ = try ExecApprovalsSQLiteStore.read(stateDirectoryURL: stateDirectoryURL)
                Issue.record("Expected pending legacy approvals to refuse SQLite access")
            } catch let error as ExecApprovalsLegacyMigrationRequiredError {
                #expect(error.stateDirectoryURL == stateDirectoryURL)
                #expect(error.legacyFileURL == legacyURL)
                // The blocked state directory must be named; a bare command repairs the
                // default root, and an app store never shares the CLI's default root.
                #expect(
                    error.localizedDescription.contains(
                        "Run `openclaw doctor --fix` with OPENCLAW_STATE_DIR set to "
                            + stateDirectoryURL.path))
            } catch {
                Issue.record("Expected typed migration error, got \(error)")
            }
        }
    }

    @Test
    func `Doctor recovery rename cannot cross an absent probe window`() {
        var probeResults = [false, false, true]
        #expect(throws: (any Error).self) {
            try ExecApprovalsLegacyMigrationGate.assertReady(
                stateDirectoryURL: URL(fileURLWithPath: "/unused"),
                pathMayExist: { _ in probeResults.removeFirst() })
        }
        #expect(probeResults.isEmpty)
    }

    private struct Projection: Equatable {
        let socketPath: String?
        let hasSocketToken: Int64
        let defaultSecurity: String?
        let defaultAsk: String?
        let defaultAskFallback: String?
        let autoAllowSkills: Int64?
        let agentCount: Int64
        let allowlistCount: Int64
        let updatedAtMilliseconds: Int64
    }

    private static func document(token: String, agentCount: Int) -> ExecApprovalsDocument {
        let agents = Dictionary(uniqueKeysWithValues: (0..<agentCount).map { index in
            ("agent-\(index)", ExecApprovalsAgentDocument(
                allowlist: [ExecApprovalsAllowlistEntry(
                    id: "entry-\(index)",
                    pattern: "/usr/bin/tool-\(index)")]))
        })
        return ExecApprovalsDocument(
            version: 1,
            socket: ExecApprovalsSocketDocument(path: "/tmp/openclaw.sock", token: token),
            defaults: ExecApprovalsDefaultsDocument(
                security: .full,
                ask: .off,
                askFallback: .deny,
                autoAllowSkills: false),
            agents: agents)
    }

    private static func readProjection(_ databaseURL: URL) throws -> Projection {
        var database: OpaquePointer?
        guard sqlite3_open(databaseURL.path, &database) == SQLITE_OK, let database else {
            throw TestError.sqlite("Could not open test database")
        }
        defer { sqlite3_close(database) }
        var statement: OpaquePointer?
        let sql = """
        SELECT socket_path, has_socket_token, default_security, default_ask,
               default_ask_fallback, auto_allow_skills, agent_count,
               allowlist_count, updated_at_ms
        FROM exec_approvals_config WHERE config_key = 'current'
        """
        guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK,
              let statement,
              sqlite3_step(statement) == SQLITE_ROW
        else {
            throw TestError.sqlite(String(cString: sqlite3_errmsg(database)))
        }
        defer { sqlite3_finalize(statement) }
        return Projection(
            socketPath: self.optionalText(statement, 0),
            hasSocketToken: sqlite3_column_int64(statement, 1),
            defaultSecurity: self.optionalText(statement, 2),
            defaultAsk: self.optionalText(statement, 3),
            defaultAskFallback: self.optionalText(statement, 4),
            autoAllowSkills: sqlite3_column_type(statement, 5) == SQLITE_NULL
                ? nil
                : sqlite3_column_int64(statement, 5),
            agentCount: sqlite3_column_int64(statement, 6),
            allowlistCount: sqlite3_column_int64(statement, 7),
            updatedAtMilliseconds: sqlite3_column_int64(statement, 8))
    }

    private static func optionalText(_ statement: OpaquePointer, _ column: Int32) -> String? {
        sqlite3_column_text(statement, column).map { String(cString: $0) }
    }

    private static func execute(_ databaseURL: URL, _ sql: String) throws {
        var database: OpaquePointer?
        guard sqlite3_open(databaseURL.path, &database) == SQLITE_OK, let database else {
            throw TestError.sqlite("Could not open test database")
        }
        defer { sqlite3_close(database) }
        guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else {
            throw TestError.sqlite(String(cString: sqlite3_errmsg(database)))
        }
    }

    private func withStateDirectory(
        _ body: (URL) throws -> Void) throws
    {
        let stateDirectoryURL = FileManager.default.temporaryDirectory
            .appendingPathComponent(UUID().uuidString, isDirectory: true)
        try FileManager.default.createDirectory(
            at: stateDirectoryURL,
            withIntermediateDirectories: true)
        defer { try? FileManager.default.removeItem(at: stateDirectoryURL) }
        try body(stateDirectoryURL)
    }

    private enum TestError: Error {
        case sqlite(String)
    }
}