From 226d6b67b714596708a00a10a0eba774c4765862 Mon Sep 17 00:00:00 2001 From: odd-bot Date: Mon, 9 Nov 2026 19:01:06 -0500 Subject: [PATCH 1/2] Run Builders on a Mac (#7) A native macOS build alongside the Windows one, plus two fixes the Mac testing turned up. - MCP replies are flushed as they're printed. Release builds buffer piped stdout on macOS, so an agent never heard back until it disconnected. - Exporting without replacing no longer goes through a shell. OS.execute runs its command through one on macOS, so a folder name containing $(...) ran as a command. ln is now started directly, and hostile names and eight racing publishes are tested. - Cmd shortcuts on a Mac (undo, redo, save), and Mac wording: Trash, Show in Finder, Terminal, and connect commands quoted for zsh. - A macOS export preset (universal, ETC2/ASTC textures for Apple Silicon). tools/package.ps1 (was package_windows.ps1) takes -Platform macOS: kits go in Contents/Resources, the app is ad-hoc signed again and zipped with ditto. - CI builds, tests, packages and smoke-tests the Mac app on macos-latest, and releases attach AgentGuildBuilders-macos.zip. - The README and the agent page cover installing on a Mac and opening an unsigned app the first time. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 86 ++++++++++++++++++++- README.md | 16 ++++ export_presets.cfg | 22 ++++++ project.godot | 2 + site/index.md | 87 +++++++++++++++++++++- src/core/library.gd | 2 +- src/export/package.gd | 17 ++++- src/mcp/tools.gd | 2 +- src/ui/editor_view.gd | 10 ++- src/ui/main.gd | 33 ++++---- tests/mcp_smoke.mjs | 4 +- tests/package_hardening.gd | 39 ++++++++++ tests/run.gd | 25 ++++++- tests/site_commands.gd | 18 +++-- tools/{package_windows.ps1 => package.ps1} | 29 ++++++-- 15 files changed, 348 insertions(+), 44 deletions(-) rename tools/{package_windows.ps1 => package.ps1} (75%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a11f160..c3e5447 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,7 +85,7 @@ jobs: - name: File lock checks run: ./tests/file_locks.ps1 -Godot $env:GODOT - name: Package - run: ./tools/package_windows.ps1 -Godot $env:GODOT + run: ./tools/package.ps1 -Godot $env:GODOT - name: Run the packaged app run: | Expand-Archive build/AgentGuildBuilders-windows.zip .smoke @@ -102,9 +102,86 @@ jobs: compression-level: 0 retention-days: 1 + build-macos: + runs-on: macos-latest + timeout-minutes: 45 + defaults: + run: + shell: pwsh + env: + AGENT_GUILD_CHECKOUT: ${{ github.workspace }}/.agent-guild + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: oddessentials/agent-guild + ref: ${{ env.AGENT_GUILD_COMMIT }} + path: .agent-guild + sparse-checkout: web/yard/assets/gnomeland + - name: Find Godot's folders + run: | + Add-Content $env:GITHUB_ENV "TEMPLATES=$HOME/Library/Application Support/Godot/export_templates" + Add-Content $env:GITHUB_ENV "GODOT=$PWD/.godot-bin/Godot.app/Contents/MacOS/Godot" + - uses: actions/cache@v4 + id: godot + with: + path: | + .godot-bin + ${{ env.TEMPLATES }} + key: godot-${{ env.GODOT_VERSION }}-macos + - name: Install Godot + if: steps.godot.outputs.cache-hit != 'true' + run: | + $base = "https://github.com/godotengine/godot/releases/download/$env:GODOT_VERSION-stable" + Invoke-WebRequest "$base/Godot_v$env:GODOT_VERSION-stable_macos.universal.zip" -OutFile editor.zip + ditto -x -k editor.zip .godot-bin + Remove-Item editor.zip + Invoke-WebRequest "$base/Godot_v$env:GODOT_VERSION-stable_export_templates.tpz" -OutFile templates.zip + $dest = "$env:TEMPLATES/$env:GODOT_VERSION.stable" + New-Item -ItemType Directory -Force $dest | Out-Null + unzip -j templates.zip templates/macos.zip templates/version.txt -d $dest + if ($LASTEXITCODE -ne 0) { throw 'Extracting the macOS template failed.' } + Remove-Item templates.zip + - uses: actions/cache@v4 + with: + path: build/kit-*.tar.gz + key: kit-${{ hashFiles('kits/kit-lock.json') }} + - name: Fetch the kit + run: python3 tools/fetch_kit.py + - name: Import + run: | + & $env:GODOT --headless --path . --editor --import --quit + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Test + run: | + foreach ($suite in 'run', 'hardening', 'clearance_hardening', 'package_hardening', 'site_commands') { + & $env:GODOT --headless --path . --script "res://tests/$suite.gd" + if ($LASTEXITCODE -ne 0) { throw "$suite failed" } + } + - name: Package + run: ./tools/package.ps1 -Godot $env:GODOT -Platform macOS + - name: Run the packaged app + run: | + ditto -x -k build/AgentGuildBuilders-macos.zip .smoke + codesign --verify --deep --strict .smoke/AgentGuildBuilders.app + if ($LASTEXITCODE -ne 0) { throw 'The packaged app is not validly signed.' } + $exe = '.smoke/AgentGuildBuilders.app/Contents/MacOS/Agent Guild Builders' + $world = Join-Path $PWD '.smoke/world' + & $exe --headless -- --new=meadow --title=Smoke "--save=$world" + if ($LASTEXITCODE -ne 0 -or -not (Test-Path "$world/world.json")) { throw 'The packaged app did not create a world.' } + node tests/mcp_smoke.mjs $exe + if ($LASTEXITCODE -ne 0) { throw 'The packaged app did not serve MCP.' } + - uses: actions/upload-artifact@v4 + if: github.event_name == 'push' + with: + name: macos-zip + path: build/AgentGuildBuilders-macos.zip + compression-level: 0 + retention-days: 1 + release: if: github.event_name == 'push' - needs: build + needs: [build, build-macos] runs-on: ubuntu-latest permissions: contents: write @@ -112,10 +189,13 @@ jobs: - uses: actions/download-artifact@v4 with: name: windows-zip + - uses: actions/download-artifact@v4 + with: + name: macos-zip - name: Publish env: GH_TOKEN: ${{ github.token }} run: | - gh release create "build-${{ github.run_number }}" AgentGuildBuilders-windows.zip \ + gh release create "build-${{ github.run_number }}" AgentGuildBuilders-windows.zip AgentGuildBuilders-macos.zip \ --repo "${{ github.repository }}" --target "${{ github.sha }}" \ --title "Build ${{ github.run_number }}" --generate-notes --latest diff --git a/README.md b/README.md index 1b93442..b5957df 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,25 @@

+

+ On a Mac (macOS 13 or later): download for Mac +

For agents

+ +
+Opening Builders on a Mac for the first time + +Builders isn't notarized by Apple, so macOS asks before it first opens. + +1. Unzip the download if your browser didn't, and drag `AgentGuildBuilders` into Applications. Open it from there, so agents you connect can find it. +2. Open it. macOS says it can't verify the app. Choose Done. +3. Open System Settings, then Privacy & Security. Under Security, choose Open Anyway next to AgentGuildBuilders, and confirm. + +It opens normally from then on. On macOS 13 or 14 you can instead Control-click the app, choose Open, then Open again. + +

diff --git a/export_presets.cfg b/export_presets.cfg index dcd7eac..3f2ab6f 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -17,3 +17,25 @@ script_export_mode=2 binary_format/embed_pck=true custom_template/debug="" custom_template/release="" + +[preset.1] + +name="macOS" +platform="macOS" +runnable=true +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="data/*" +exclude_filter="kits/*, tests/*, tools/*" +export_path="build/AgentGuildBuilders.app" +script_export_mode=2 + +[preset.1.options] + +binary_format/architecture="universal" +custom_template/debug="" +custom_template/release="" +application/bundle_identifier="com.oddessentials.agentguildbuilders" +codesign/codesign=1 diff --git a/project.godot b/project.godot index 8148695..3d45e57 100644 --- a/project.godot +++ b/project.godot @@ -4,6 +4,7 @@ config_version=5 config/name="Agent Guild Builders" config/description="Build an environment for your Agent Guild Yard." run/print_header=false +run/flush_stdout_on_print=true run/main_scene="res://src/ui/main.tscn" config/features=PackedStringArray("4.7", "Forward Plus") @@ -16,3 +17,4 @@ window/stretch/aspect="expand" lights_and_shadows/directional_shadow/soft_shadow_filter_quality=3 lights_and_shadows/directional_shadow/size=8192 textures/default_filters/anisotropic_filtering_level=4 +textures/vram_compression/import_etc2_astc=true diff --git a/site/index.md b/site/index.md index e7df1c1..6c7413c 100644 --- a/site/index.md +++ b/site/index.md @@ -1,10 +1,10 @@ # Set up Agent Guild: Builders -Agent Guild: Builders makes environments for Agent Guild Yards. It runs on Windows 10 or 11 (64-bit) and opens a window the person can watch while you build. +Agent Guild: Builders makes environments for Agent Guild Yards. It runs on Windows 10 or 11 (64-bit) or macOS 13 or later, and opens a window the person can watch while you build. Agent Guild itself: [agentguild.oddessentials.ai/agents](https://agentguild.oddessentials.ai/agents/). Characters and companions for Agent Guild: World: [oddessentials.github.io/agent-guild-steam](https://oddessentials.github.io/agent-guild-steam/). -Follow these steps in order. Run each block in PowerShell. If your shell isn't PowerShell, save the block as a `.ps1` file and run `powershell -NoProfile -ExecutionPolicy Bypass -File `. +On a Mac, follow the Mac steps further down instead. On Windows, follow these steps in order. Run each block in PowerShell. If your shell isn't PowerShell, save the block as a `.ps1` file and run `powershell -NoProfile -ExecutionPolicy Bypass -File `. ## 1. Choose the folder @@ -98,3 +98,86 @@ Builders is now set up. Its tools explain how to build. ## Update Run step 2 again with the same folder. The connection and the person's worlds are kept. Then start a new session, so the Builders tools load again. + +## On a Mac + +Follow these steps in order. Run each block in Terminal's shell, zsh, or in bash. + +## Mac 1. Choose the folder + +Every block below starts with this line, which puts Builders in `~/Applications`: + +```zsh +dest="$HOME/Applications" +``` + +Change that line in every block, to `dest=''` with any `'` in the folder written as `'\''`, if: + +- the person named a folder: use it. +- Builders is already connected to you or another agent: use the folder that contains its `AgentGuildBuilders.app`. Find the connected path as in step 1 above. + +Don't move Builders after connecting it. + +## Mac 2. Download and install + +If Builders is already running, ask the person to close its window first. The download is large. + +```zsh +dest="$HOME/Applications" +tmp="$(mktemp -d)" && +curl -fsSL -o "$tmp/AgentGuildBuilders-macos.zip" 'https://github.com/oddessentials/agent-guild-builders/releases/latest/download/AgentGuildBuilders-macos.zip' && +ditto -x -k "$tmp/AgentGuildBuilders-macos.zip" "$tmp" && +mkdir -p "$dest" && +rm -rf "$dest/AgentGuildBuilders.app" && +mv "$tmp/AgentGuildBuilders.app" "$dest/" && +rm -rf "$tmp" && +echo "$dest/AgentGuildBuilders.app/Contents/MacOS/Agent Guild Builders" +``` + +It prints the full path of the app's executable. Installed this way, macOS doesn't ask the person before Builders first opens. + +## Mac 3. Connect + +Skip this step if Builders is already connected to you. Otherwise run the block for the agent you are. + +Claude Code: + +```zsh +dest="$HOME/Applications" +exe="$dest/AgentGuildBuilders.app/Contents/MacOS/Agent Guild Builders" +claude mcp add -s user agent-guild-builders "--" "$exe" --no-header "--" --mcp +``` + +Codex CLI: + +```zsh +dest="$HOME/Applications" +exe="$dest/AgentGuildBuilders.app/Contents/MacOS/Agent Guild Builders" +codex mcp add agent-guild-builders "--" "$exe" --no-header "--" --mcp +``` + +Antigravity CLI: + +```zsh +dest="$HOME/Applications" +exe="$dest/AgentGuildBuilders.app/Contents/MacOS/Agent Guild Builders" +agy mcp add agent-guild-builders "--" "$exe" --no-header "--" --mcp +``` + +Grok Build: + +```zsh +dest="$HOME/Applications" +exe="$dest/AgentGuildBuilders.app/Contents/MacOS/Agent Guild Builders" +grok mcp add agent-guild-builders "--" "$exe" --no-header "--" --mcp +``` + +Docker Agent: add the toolset shown in step 3 above, with `` the path Mac step 2 printed. + +## Mac 4. Check that it works + +Follow step 4 above. + +## Mac update + +Run Mac step 2 again with the same folder. The connection and the person's worlds are kept. Then start a new session, so the Builders tools load again. diff --git a/src/core/library.gd b/src/core/library.gd index c924d98..ad09349 100644 --- a/src/core/library.gd +++ b/src/core/library.gd @@ -84,6 +84,6 @@ static func duplicate_world(path: String) -> String: DirAccess.copy_absolute(path.path_join(WorldDoc.THUMB), folder.path_join(WorldDoc.THUMB)) return folder -## Goes to the Recycle Bin, so a mistake can be undone. +## Goes to the Recycle Bin, or the Trash on a Mac, so a mistake can be undone. static func delete_world(path: String) -> Error: return OS.move_to_trash(path) diff --git a/src/export/package.gd b/src/export/package.gd index 753e292..241b569 100644 --- a/src/export/package.gd +++ b/src/export/package.gd @@ -240,7 +240,9 @@ static func _publish_staged(tmp: String, out_path: String, had_previous: bool, r ## Publishes without ever replacing, which a rename can't promise: Windows' ## plain File.Move and a hard link both fail atomically if the name is taken. -## Paths go through the environment, never through a shell or script text. +## Paths never pass through a shell or script text: on Windows they go through +## the environment, and elsewhere ln is started directly, because OS.execute +## runs its command through a shell on macOS. static func _publish_new(tmp: String, out_path: String, report: Dictionary) -> Error: var bytes := FileAccess.get_file_as_bytes(tmp) var output := [] @@ -252,7 +254,7 @@ static func _publish_new(tmp: String, out_path: String, report: Dictionary) -> E OS.unset_environment("AGB_PUBLISH_FROM") OS.unset_environment("AGB_PUBLISH_TO") else: - moved = OS.execute("ln", [tmp, out_path], output, true) + moved = _hard_link(ProjectSettings.globalize_path(tmp), ProjectSettings.globalize_path(out_path)) var published := moved == 0 and FileAccess.file_exists(out_path) and FileAccess.get_file_as_bytes(out_path) == bytes if FileAccess.file_exists(tmp): DirAccess.remove_absolute(tmp) @@ -262,6 +264,17 @@ static func _publish_new(tmp: String, out_path: String, report: Dictionary) -> E return _occupied(report, out_path) return _write_error(report, FAILED, "publish the export without risking a replacement", out_path) +## /bin/ln with its own pipes, so the paths reach it as they are and it never +## shares stdin or stdout, which carry MCP. Returns ln's exit code, or -1. +static func _hard_link(from: String, to: String) -> int: + var started := OS.execute_with_pipe("/bin/ln", [from, to], false) + var pid: int = started.get("pid", -1) + if pid <= 0: + return -1 + while OS.is_process_running(pid): + OS.delay_msec(1) + return OS.get_process_exit_code(pid) + ## Windows' extended-length form: no 260-character limit, and taken literally. static func _extended_path(path: String) -> String: var p := ProjectSettings.globalize_path(path).simplify_path().replace("/", "\\") diff --git a/src/mcp/tools.gd b/src/mcp/tools.gd index 006d03a..69a9def 100644 --- a/src/mcp/tools.gd +++ b/src/mcp/tools.gd @@ -229,7 +229,7 @@ static func _flat(points: Array) -> Array: static func export_problem(to: String, replace: bool) -> String: var path := to.replace("\\", "/") if not path.is_absolute_path() or path.begins_with("res://") or path.begins_with("user://"): - return "`to` must be a full path, such as C:/Users/you/Documents/My Yard.agworld." + return "`to` must be a full path, such as %s." % ("/Users/you/Documents/My Yard.agworld" if OS.get_name() == "macOS" else "C:/Users/you/Documents/My Yard.agworld") if path.split("/").has(".."): return "`to` can't contain `..`." if not path.to_lower().ends_with(WorldPackage.EXT): diff --git a/src/ui/editor_view.gd b/src/ui/editor_view.gd index d07f856..5a71133 100644 --- a/src/ui/editor_view.gd +++ b/src/ui/editor_view.gd @@ -381,7 +381,7 @@ func _build_ui() -> void: _status.size_flags_horizontal = Control.SIZE_EXPAND_FILL foot_row.add_child(_status) _result_button = Button.new() - _result_button.text = "Show in folder" + _result_button.text = "Show in Finder" if OS.get_name() == "macOS" else "Show in folder" _result_button.visible = false _result_button.pressed.connect(func(): OS.shell_show_in_file_manager(_result_path)) foot_row.add_child(_result_button) @@ -2006,8 +2006,9 @@ func _update_undo_buttons() -> void: var locked := _exporting _undo_button.disabled = locked or not history.can_undo() _redo_button.disabled = locked or not history.can_redo() - _undo_button.tooltip_text = "Undo: %s (Ctrl+Z)" % history.undo_label() if history.can_undo() else "Nothing to undo" - _redo_button.tooltip_text = "Redo: %s (Ctrl+Y)" % history.redo_label() if history.can_redo() else "Nothing to redo" + var mac := OS.get_name() == "macOS" + _undo_button.tooltip_text = "Undo: %s (%s)" % [history.undo_label(), "⌘Z" if mac else "Ctrl+Z"] if history.can_undo() else "Nothing to undo" + _redo_button.tooltip_text = "Redo: %s (%s)" % [history.redo_label(), "⇧⌘Z" if mac else "Ctrl+Y"] if history.can_redo() else "Nothing to redo" func _mark_dirty() -> void: _dirty_at = Time.get_ticks_msec() / 1000.0 @@ -2034,7 +2035,8 @@ func _commit_title() -> void: func _unhandled_key_input(event: InputEvent) -> void: if not event.pressed or event.echo: return - if event.ctrl_pressed: + # Cmd on a Mac, Ctrl elsewhere. + if event.is_command_or_control_pressed(): match event.keycode: KEY_Z: if event.shift_pressed: diff --git a/src/ui/main.gd b/src/ui/main.gd index 501ddbe..60b313d 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -320,7 +320,7 @@ func _world_card(w: Dictionary) -> Control: var menu := more.get_popup() menu.add_item("Rename…", 0) menu.add_item("Duplicate", 1) - menu.add_item("Move to Recycle Bin…", 2) + menu.add_item("Move to %s…" % trash_name(), 2) menu.id_pressed.connect(func(id): _on_world_menu(id, w)) row.add_child(more) return box @@ -368,15 +368,18 @@ func _rename_dialog(w: Dictionary) -> void: box.grab_focus() box.select_all() +static func trash_name() -> String: + return "Trash" if OS.get_name() == "macOS" else "Recycle Bin" + func _delete_dialog(w: Dictionary) -> void: var ask := ConfirmationDialog.new() - ask.title = "Move to Recycle Bin" - ask.dialog_text = "Move “%s” to the Recycle Bin?" % w.title - ask.ok_button_text = "Move to Recycle Bin" + ask.title = "Move to " + trash_name() + ask.dialog_text = "Move “%s” to the %s?" % [w.title, trash_name()] + ask.ok_button_text = "Move to " + trash_name() add_child(ask) ask.confirmed.connect(func(): if WorldLibrary.delete_world(w.path) != OK: - _notice = "That world couldn't be moved to the Recycle Bin." + _notice = "That world couldn't be moved to the %s." % trash_name() _start_screen()) ask.canceled.connect(ask.queue_free) ask.popup_centered() @@ -546,10 +549,11 @@ func agent_disconnected() -> void: else: get_tree().quit() -static func connect_commands(exe: String, before: Array) -> Array: +## For PowerShell on Windows, and for Terminal's shell (zsh) elsewhere. +static func connect_commands(exe: String, before: Array, posix := OS.get_name() != "Windows") -> Array: var args := before + ["--no-header", "--", "--mcp"] - var shell_args := " ".join(args.map(func(a): return _shell_quote(str(a)))) - var line := "%s %s" % [_shell_quote(exe), shell_args] + var shell_args := " ".join(args.map(func(a): return _shell_quote(str(a), posix))) + var line := "%s %s" % [_shell_quote(exe, posix), shell_args] var yaml_args := ", ".join(args.map(func(a): return "'%s'" % str(a).replace("'", "''"))) return [ ["Claude Code", "claude mcp add -s user agent-guild-builders \"--\" " + line], @@ -559,15 +563,16 @@ static func connect_commands(exe: String, before: Array) -> Array: ["Docker Agent", "agents:\n root:\n toolsets:\n - type: mcp\n command: '%s'\n args: [%s]" % [exe.replace("'", "''"), yaml_args]], ] -## Quoted for PowerShell: paths always in single quotes, which PowerShell -## takes literally. A bare -- would be dropped by PowerShell when the agent's -## command is an npm .ps1 wrapper, so it's always quoted. -static func _shell_quote(text: String) -> String: +## Paths always in single quotes, which PowerShell and POSIX shells both take +## literally; they differ only in how a ' inside is written. A bare -- would be +## dropped by PowerShell when the agent's command is an npm .ps1 wrapper, so +## it's always quoted. +static func _shell_quote(text: String, posix := false) -> String: if text == "--": return "\"--\"" if text.begins_with("--") and text.substr(2).replace("-", "").is_valid_identifier(): return text - return "'%s'" % text.replace("'", "''") + return "'%s'" % text.replace("'", "'\\''" if posix else "''") func _connect_dialog(exe := OS.get_executable_path()) -> void: var ask := AcceptDialog.new() @@ -577,7 +582,7 @@ func _connect_dialog(exe := OS.get_executable_path()) -> void: var box := VBoxContainer.new() box.add_theme_constant_override("separation", 6) ask.add_child(box) - var intro := ProTheme.note("Run one of these once in PowerShell. Afterwards your agent opens its own Builders window whenever it needs one, and you can watch and undo everything it does. If you move this app to another folder, run the command again.") + var intro := ProTheme.note("Run one of these once in %s. Afterwards your agent opens its own Builders window whenever it needs one, and you can watch and undo everything it does. If you move this app to another folder, run the command again." % ("Terminal" if OS.get_name() == "macOS" else "PowerShell")) intro.custom_minimum_size = Vector2(CONNECT_WIDTH, 0) box.add_child(intro) var scroll := ScrollContainer.new() diff --git a/tests/mcp_smoke.mjs b/tests/mcp_smoke.mjs index 5f511ed..7561fc5 100644 --- a/tests/mcp_smoke.mjs +++ b/tests/mcp_smoke.mjs @@ -1,5 +1,6 @@ // Drives the packaged app's MCP server the way an agent's client does: // node tests/mcp_smoke.mjs path/to/AgentGuildBuilders.exe [--windowed] +// (on a Mac, the executable inside AgentGuildBuilders.app/Contents/MacOS) import { spawn } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; @@ -9,7 +10,8 @@ const [exe, ...flags] = process.argv.slice(2); const windowed = flags.includes('--windowed'); const appData = fs.mkdtempSync(path.join(os.tmpdir(), 'agb-mcp-')); const args = [...(windowed ? [] : ['--headless']), '--', '--mcp']; -const child = spawn(exe, args, { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, APPDATA: appData } }); +// Builders keeps its data under APPDATA on Windows and under HOME on a Mac. +const child = spawn(exe, args, { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, APPDATA: appData, ...(process.platform === 'darwin' ? { HOME: appData } : {}) } }); const lines = []; const waiting = new Map(); let buffer = ''; diff --git a/tests/package_hardening.gd b/tests/package_hardening.gd index 1d11f11..b0013e0 100644 --- a/tests/package_hardening.gd +++ b/tests/package_hardening.gd @@ -82,6 +82,45 @@ func _no_replace() -> bool: for name in DirAccess.get_files_at(odd_folder): DirAccess.remove_absolute(odd_folder.path_join(name)) DirAccess.remove_absolute(odd_folder) + var marker := OS.get_environment("TMPDIR").path_join("agb-injected") if OS.get_environment("TMPDIR") != "" else "" + var hostile := ["run $(touch ${TMPDIR}agb-injected)", "tick `touch ${TMPDIR}agb-injected`", "a; touch b && c", "$HOME %PATH%"] + if OS.get_name() != "Windows": + hostile += ["pipe | cat", "star *", "quote \" here", "new\nline", "-n"] + for name in hostile: + var folder := work.path_join(name) + DirAccess.make_dir_recursive_absolute(folder) + var target := folder.path_join("Yard.agworld") + check(WorldPackage._write_package(bytes, target, {}, false) == OK and FileAccess.get_file_as_bytes(target) == bytes and not FileAccess.file_exists(target + ".tmp"), "published literally into a folder named %s" % name.c_escape()) + _store(target + ".tmp", other) + report = {} + check(WorldPackage._publish_new(target + ".tmp", target, report) == ERR_ALREADY_EXISTS and FileAccess.get_file_as_bytes(target) == bytes, "there, a taken name is still refused: %s" % name.c_escape()) + DirAccess.remove_absolute(target) + DirAccess.remove_absolute(folder) + check(marker == "" or not FileAccess.file_exists(marker), "no folder name runs a command") + # Windows passes paths through the process environment, so racing threads + # in one process would share it; separate processes don't, and are what + # can race there. + if OS.get_name() != "Windows": + var contested := work.path_join("contested.agworld") + var threads: Array[Thread] = [] + var outcomes := [] + outcomes.resize(8) + for i in outcomes.size(): + var mine := bytes.duplicate() + mine.append(i) + _store(contested + ".%d.tmp" % i, mine) + for i in outcomes.size(): + var t := Thread.new() + t.start(func(): outcomes[i] = WorldPackage._publish_new(contested + ".%d.tmp" % i, contested, {})) + threads.append(t) + for t in threads: + t.wait_to_finish() + var winners := range(outcomes.size()).filter(func(i): return outcomes[i] == OK) + var final := FileAccess.get_file_as_bytes(contested) + check(winners.size() == 1 and outcomes.count(ERR_ALREADY_EXISTS) == outcomes.size() - 1, "of 8 racing publishes, exactly one wins and the rest are refused") + check(winners.size() == 1 and final.size() == bytes.size() + 1 and final[-1] == winners[0], "the destination holds the winner's file, untouched by the others") + check(range(outcomes.size()).all(func(i): return not FileAccess.file_exists(contested + ".%d.tmp" % i)), "racing publishes leave nothing behind") + DirAccess.remove_absolute(contested) var long_root := work.path_join("long") var long_folder := long_root while long_folder.length() < 230: diff --git a/tests/run.gd b/tests/run.gd index b112e56..044a65f 100644 --- a/tests/run.gd +++ b/tests/run.gd @@ -432,6 +432,21 @@ func _editor() -> bool: check(doc.data.things.is_empty(), "editor: undo removes it") view.history.redo() check(doc.data.things.size() == 1, "editor: redo restores it") + var command := func(code: Key, shift := false) -> void: + var key := InputEventKey.new() + key.pressed = true + key.keycode = code + key.shift_pressed = shift + key.meta_pressed = OS.get_name() == "macOS" + key.ctrl_pressed = OS.get_name() != "macOS" + view._unhandled_key_input(key) + var tool_before = view.tool + command.call(KEY_Z) + check(doc.data.things.is_empty(), "editor: Cmd+Z on a Mac, Ctrl+Z elsewhere, undoes") + command.call(KEY_Z, true) + check(doc.data.things.size() == 1, "editor: with Shift it redoes") + command.call(KEY_S) + check(view.tool == tool_before, "editor: the save shortcut doesn't also pick the tool on S") var plaza_hit: Vector2 = view._camera.unproject_position(Vector3(0, 0, 0)) _click(view, plaza_hit, true) _click(view, plaza_hit, false) @@ -2158,15 +2173,19 @@ func _mcp() -> bool: DirAccess.remove_absolute(docs) DirAccess.remove_absolute(outside) - var commands: Array = load("res://src/ui/main.gd").connect_commands("C:/Program Files/Agent Guild Builders/AgentGuildBuilders.exe", []) + var commands: Array = load("res://src/ui/main.gd").connect_commands("C:/Program Files/Agent Guild Builders/AgentGuildBuilders.exe", [], false) check(commands.map(func(c): return c[0]) == ["Claude Code", "Codex CLI", "Antigravity CLI", "Grok Build", "Docker Agent"], "mcp: connect commands cover every Agent Guild provider") check(commands[0][1] == "claude mcp add -s user agent-guild-builders \"--\" 'C:/Program Files/Agent Guild Builders/AgentGuildBuilders.exe' --no-header \"--\" --mcp", "mcp: the Claude Code command quotes the path and every --") - var brackets: Array = load("res://src/ui/main.gd").connect_commands("C:/Apps(Builders)/AgentGuildBuilders.exe", []) + var brackets: Array = load("res://src/ui/main.gd").connect_commands("C:/Apps(Builders)/AgentGuildBuilders.exe", [], false) check(brackets[0][1].contains(" 'C:/Apps(Builders)/AgentGuildBuilders.exe' "), "mcp: a path PowerShell would read as an expression is single-quoted") check(commands[2][1].begins_with("agy mcp add agent-guild-builders \"--\" "), "mcp: Antigravity flags come before the name") check(commands[4][1].contains("type: mcp") and commands[4][1].contains("'--no-header', '--', '--mcp'"), "mcp: the Docker Agent toolset has the server's arguments") - var dollar: Array = load("res://src/ui/main.gd").connect_commands("C:/Apps $x/it's/AgentGuildBuilders.exe", []) + var dollar: Array = load("res://src/ui/main.gd").connect_commands("C:/Apps $x/it's/AgentGuildBuilders.exe", [], false) check(dollar[1][1].contains("'C:/Apps $x/it''s/AgentGuildBuilders.exe'"), "mcp: paths PowerShell would expand are single-quoted") + var mac: Array = load("res://src/ui/main.gd").connect_commands("/Users/x/Bill's $x `y`/AgentGuildBuilders.app/Contents/MacOS/AgentGuildBuilders", [], true) + check(mac[0][1] == "claude mcp add -s user agent-guild-builders \"--\" '/Users/x/Bill'\\''s $x `y`/AgentGuildBuilders.app/Contents/MacOS/AgentGuildBuilders' --no-header \"--\" --mcp", "mcp: on a Mac the path is single-quoted for zsh, with a ' written as '\\''") + check(mac[4][1].contains("command: '/Users/x/Bill''s $x `y`/"), "mcp: the Docker Agent YAML quotes the path the same everywhere") + check(ProjectSettings.get_setting("application/run/flush_stdout_on_print"), "mcp: every reply is flushed at once, which release builds don't do by default") var job := BakeJob.new() root.add_child(job) var ended := [] diff --git a/tests/site_commands.gd b/tests/site_commands.gd index 06da403..aa72dbb 100644 --- a/tests/site_commands.gd +++ b/tests/site_commands.gd @@ -1,15 +1,21 @@ extends SceneTree const EXE := "C:/x/AgentGuildBuilders.exe" +const MAC_EXE := "/x/AgentGuildBuilders.app/Contents/MacOS/Agent Guild Builders" func _initialize() -> void: var page := FileAccess.get_file_as_string("res://site/index.md").replace("\r\n", "\n") - var commands: Array = load("res://src/ui/main.gd").connect_commands(EXE, []) + var main: GDScript = load("res://src/ui/main.gd") + var wanted := [] + for entry in main.connect_commands(EXE, [], false): + wanted.append([entry[0], entry[1].replace("'%s'" % EXE, "$exe") if entry[0] != "Docker Agent" else entry[1].replace(EXE, "")]) + for entry in main.connect_commands(MAC_EXE, [], true): + if entry[0] != "Docker Agent": + wanted.append(["Mac " + entry[0], entry[1].replace("'%s'" % MAC_EXE, "\"$exe\"")]) var failed := 0 - for entry in commands: - var wanted: String = entry[1].replace("'%s'" % EXE, "$exe") if entry[0] != "Docker Agent" else entry[1].replace(EXE, "") - if not page.contains(wanted): + for entry in wanted: + if not page.contains(entry[1]): failed += 1 - printerr("FAIL the page's %s step isn't the Connect dialog's command: %s" % [entry[0], wanted]) - print("site commands: %d of %d match" % [commands.size() - failed, commands.size()]) + printerr("FAIL the page's %s step isn't the Connect dialog's command: %s" % entry) + print("site commands: %d of %d match" % [wanted.size() - failed, wanted.size()]) quit(1 if failed else 0) diff --git a/tools/package_windows.ps1 b/tools/package.ps1 similarity index 75% rename from tools/package_windows.ps1 rename to tools/package.ps1 index 9e74a61..982ee36 100644 --- a/tools/package_windows.ps1 +++ b/tools/package.ps1 @@ -1,4 +1,4 @@ -param([string]$Godot = 'godot') +param([string]$Godot = 'godot', [ValidateSet('Windows', 'macOS')][string]$Platform = 'Windows') $ErrorActionPreference = 'Stop' $project = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path @@ -47,14 +47,17 @@ foreach ($file in $files) { New-Item -ItemType Directory -Path $build -Force | Out-Null $stage = Join-Path $build ('package-' + [guid]::NewGuid().ToString('N')) -$bundle = Join-Path $stage 'AgentGuildBuilders' -New-Item -ItemType Directory -Path $bundle -Force | Out-Null +# On a Mac the kits and licence go inside the app, where res:// points. +$mac = $Platform -eq 'macOS' +$app = Join-Path $stage 'AgentGuildBuilders.app' +$bundle = if ($mac) { Join-Path $app 'Contents/Resources' } else { Join-Path $stage 'AgentGuildBuilders' } +New-Item -ItemType Directory -Path $(if ($mac) { $stage } else { $bundle }) -Force | Out-Null try { $importLog = & $Godot --headless --path $project --editor --import --quit 2>&1 $importExit = $LASTEXITCODE $importLog | Write-Output if ($importExit -ne 0 -or ($importLog -match 'SCRIPT ERROR:')) { throw 'Godot import failed.' } - $exportLog = & $Godot --headless --path $project --export-release Windows (Join-Path $bundle 'AgentGuildBuilders.exe') 2>&1 + $exportLog = & $Godot --headless --path $project --export-release $Platform $(if ($mac) { $app } else { Join-Path $bundle 'AgentGuildBuilders.exe' }) 2>&1 $exportExit = $LASTEXITCODE $exportLog | Write-Output if ($exportExit -ne 0 -or ($exportLog -match 'SCRIPT ERROR:')) { throw 'Godot export failed.' } @@ -64,9 +67,21 @@ try { New-Item -ItemType Directory -Path (Split-Path -Parent $destination) -Force | Out-Null Copy-Item -LiteralPath (Join-Path $project $file) -Destination $destination } - $zip = Join-Path $stage 'AgentGuildBuilders-windows.zip' - Compress-Archive -LiteralPath $bundle -DestinationPath $zip - $output = Join-Path $build 'AgentGuildBuilders-windows.zip' + $name = "AgentGuildBuilders-$($Platform.ToLower()).zip" + $zip = Join-Path $stage $name + if ($mac) { + # Adding the kits broke the export's ad-hoc signature, and Apple + # Silicon won't run an app without a valid one, so sign it again. + & codesign --force --sign - --preserve-metadata=identifier,entitlements,flags,runtime $app + if ($LASTEXITCODE -ne 0) { throw 'Signing the app failed.' } + & codesign --verify --deep --strict $app + if ($LASTEXITCODE -ne 0) { throw 'The signed app does not verify.' } + & ditto -c -k --keepParent $app $zip + if ($LASTEXITCODE -ne 0) { throw 'Zipping the app failed.' } + } else { + Compress-Archive -LiteralPath $bundle -DestinationPath $zip + } + $output = Join-Path $build $name Move-Item -LiteralPath $zip -Destination $output -Force Write-Output "Packaged $output" } finally { From dd5cf239ae6286f0081994020be4edbd6b31f268 Mon Sep 17 00:00:00 2001 From: odd-bot Date: Mon, 9 Nov 2026 19:30:25 -0500 Subject: [PATCH 2/2] Track the uid for tests/site_commands.gd Every other test script's uid is tracked; this one was missed in #10/#11. Co-Authored-By: Claude Opus 5.5 --- tests/site_commands.gd.uid | 1 + 1 file changed, 1 insertion(+) create mode 100644 tests/site_commands.gd.uid diff --git a/tests/site_commands.gd.uid b/tests/site_commands.gd.uid new file mode 100644 index 0000000..31e7790 --- /dev/null +++ b/tests/site_commands.gd.uid @@ -0,0 +1 @@ +uid://bwb13ifklnoki