diff --git a/apps/macos/Sources/Clawdis/AgentWorkspace.swift b/apps/macos/Sources/Clawdis/AgentWorkspace.swift index 1aaaa4d43..565666da1 100644 --- a/apps/macos/Sources/Clawdis/AgentWorkspace.swift +++ b/apps/macos/Sources/Clawdis/AgentWorkspace.swift @@ -11,6 +11,10 @@ enum AgentWorkspace { private static let templateDirname = "templates" static let identityStartMarker = "" static let identityEndMarker = "" + enum BootstrapSafety: Equatable { + case safe + case unsafe(reason: String) + } static func displayPath(for url: URL) -> String { let home = FileManager.default.homeDirectoryForCurrentUser.path @@ -33,6 +37,31 @@ enum AgentWorkspace { workspaceURL.appendingPathComponent(self.agentsFilename) } + static func bootstrapSafety(for workspaceURL: URL) -> BootstrapSafety { + let fm = FileManager.default + var isDir: ObjCBool = false + if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { + return .safe + } + if !isDir.boolValue { + return .unsafe(reason: "Workspace path points to a file.") + } + let agentsURL = self.agentsURL(workspaceURL: workspaceURL) + if fm.fileExists(atPath: agentsURL.path) { + return .safe + } + do { + let contents = try fm.contentsOfDirectory(atPath: workspaceURL.path) + let ignored: Set = [".DS_Store", ".git", ".gitignore"] + let filtered = contents.filter { !ignored.contains($0) } + return filtered.isEmpty + ? .safe + : .unsafe(reason: "Folder isn't empty. Choose a new folder or add AGENTS.md first.") + } catch { + return .unsafe(reason: "Couldn't inspect the workspace folder.") + } + } + static func bootstrap(workspaceURL: URL) throws -> URL { try FileManager.default.createDirectory(at: workspaceURL, withIntermediateDirectories: true) let agentsURL = self.agentsURL(workspaceURL: workspaceURL) diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index 3db1daeda..293027dc5 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -1378,13 +1378,18 @@ struct OnboardingView: View { guard self.state.connectionMode == .local else { return } let configured = ClawdisConfigFile.inboundWorkspace() let url = AgentWorkspace.resolveWorkspaceURL(from: configured) - do { - _ = try AgentWorkspace.bootstrap(workspaceURL: url) - if (configured ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - ClawdisConfigFile.setInboundWorkspace(AgentWorkspace.displayPath(for: url)) + switch AgentWorkspace.bootstrapSafety(for: url) { + case .safe: + do { + _ = try AgentWorkspace.bootstrap(workspaceURL: url) + if (configured ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + ClawdisConfigFile.setInboundWorkspace(AgentWorkspace.displayPath(for: url)) + } + } catch { + self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)" } - } catch { - self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)" + case let .unsafe(reason): + self.workspaceStatus = "Workspace not touched: \(reason)" } } @@ -1426,6 +1431,10 @@ struct OnboardingView: View { do { let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath) + if case let .unsafe(reason) = AgentWorkspace.bootstrapSafety(for: url) { + self.workspaceStatus = "Workspace not created: \(reason)" + return + } _ = try AgentWorkspace.bootstrap(workspaceURL: url) self.workspacePath = AgentWorkspace.displayPath(for: url) self.workspaceStatus = "Workspace ready at \(self.workspacePath)" diff --git a/apps/macos/Tests/ClawdisIPCTests/AgentWorkspaceTests.swift b/apps/macos/Tests/ClawdisIPCTests/AgentWorkspaceTests.swift index 7a6ef8429..507f601b7 100644 --- a/apps/macos/Tests/ClawdisIPCTests/AgentWorkspaceTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/AgentWorkspaceTests.swift @@ -48,4 +48,40 @@ struct AgentWorkspaceTests { let second = try AgentWorkspace.bootstrap(workspaceURL: tmp) #expect(second == agentsURL) } + + @Test + func bootstrapSafetyRejectsNonEmptyFolderWithoutAgents() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("clawdis-ws-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: tmp) } + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + let marker = tmp.appendingPathComponent("notes.txt") + try "hello".write(to: marker, atomically: true, encoding: .utf8) + + let result = AgentWorkspace.bootstrapSafety(for: tmp) + switch result { + case .unsafe: + break + case .safe: + #expect(false, "Expected unsafe bootstrap safety result.") + } + } + + @Test + func bootstrapSafetyAllowsExistingAgentsFile() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("clawdis-ws-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: tmp) } + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + let agents = tmp.appendingPathComponent(AgentWorkspace.agentsFilename) + try "# AGENTS.md".write(to: agents, atomically: true, encoding: .utf8) + + let result = AgentWorkspace.bootstrapSafety(for: tmp) + switch result { + case .safe: + break + case .unsafe: + #expect(false, "Expected safe bootstrap safety result.") + } + } } diff --git a/docs/onboarding.md b/docs/onboarding.md index ef1c7da17..ab82062ba 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -94,7 +94,8 @@ Once setup is complete, the user can switch to the normal chat (`main`) via the We no longer collect identity in the onboarding wizard. Instead, the **first agent run** performs a playful bootstrap ritual using files in the workspace: -- Workspace is created implicitly (default `~/.clawdis/workspace`) when local is selected. +- Workspace is created implicitly (default `~/.clawdis/workspace`) when local is selected, + but only if the folder is empty or already contains `AGENTS.md`. - Files are seeded: `AGENTS.md`, `BOOTSTRAP.md`, `IDENTITY.md`, `USER.md`. - `BOOTSTRAP.md` tells the agent to keep it conversational: - open with a cute hello