fix(mac): guard onboarding workspace bootstrap

This commit is contained in:
Peter Steinberger
2025-12-21 01:31:31 +00:00
parent 4e1fe88195
commit 00cdcd4d28
4 changed files with 82 additions and 7 deletions

View File

@@ -11,6 +11,10 @@ enum AgentWorkspace {
private static let templateDirname = "templates"
static let identityStartMarker = "<!-- clawdis:identity:start -->"
static let identityEndMarker = "<!-- clawdis:identity:end -->"
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<String> = [".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)

View File

@@ -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)"

View File

@@ -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.")
}
}
}

View File

@@ -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