From 9f6774462e1ef79211c18a9f3cbf4124a2a7e776 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Thu, 10 Sep 2026 08:47:11 +0800 Subject: [PATCH] Add native macOS Desktop and Agent setup prompts ## Why Desktop lacks a native macOS setup path and first-use Agent guidance in the public source. ## What changed - Add macOS runtimes, Python checks, login shells and arm64 DMG packaging. - Expose opt-in Agent prompts and bundled skill/helper discovery. - Preserve Windows/WSL launchers and human approval; reject incompatible backends without cross-platform fallback. ## Testing Cover Desktop units, Core headless checks, isolated bootstrap/runtime behavior, and Electron capture on Windows, WSL and WSLg. Verify Mac delivery build inputs and the shared Core manifest. --- app.py | 30 +++++-- desktop/README.md | 100 +++++++++++++++++++++-- desktop/agent-menu.cjs | 45 ++++++++++ desktop/backend.py | 3 + desktop/bootstrap.py | 3 +- desktop/browser-session.cjs | 2 +- desktop/core-files.cjs | 42 ++++++++++ desktop/desktop-mode.cjs | 15 ++-- desktop/diagnostics.cjs | 4 +- desktop/electron-builder.cjs | 13 ++- desktop/installer-shortcuts.cjs | 2 +- desktop/macos-python.cjs | 28 +++++++ desktop/main.cjs | 19 ++++- desktop/package-lock.json | 4 +- desktop/package.json | 6 +- desktop/runtime.py | 5 +- desktop/setup.cjs | 51 +++++++++--- desktop/smoke.cjs | 24 +++++- desktop/squirrel-events.cjs | 2 +- desktop/stage-windows.cjs | 31 +++++-- desktop/test/agent-menu.test.cjs | 46 +++++++++++ desktop/test/backend_smoke.py | 10 +++ desktop/test/bootstrap_smoke.py | 14 ++-- desktop/test/browser-storage-smoke.cjs | 7 +- desktop/test/capture-smoke.cjs | 4 + desktop/test/core-files.test.cjs | 50 ++++++++++++ desktop/test/macos-setup-integration.cjs | 65 +++++++++++++++ desktop/test/macos.test.cjs | 47 +++++++++++ desktop/test/macos_shell_smoke.py | 72 ++++++++++++++++ desktop/test/package-inspect.cjs | 4 +- desktop/test/runtime_smoke.py | 7 +- desktop/test/setup.test.cjs | 44 ++++++++-- desktop/test/squirrel-events.test.cjs | 4 +- docs/agent_socket_contract.md | 16 ++++ tests/agent_backend_smoke.py | 25 ++++++ 35 files changed, 772 insertions(+), 72 deletions(-) create mode 100644 desktop/agent-menu.cjs create mode 100644 desktop/core-files.cjs create mode 100644 desktop/macos-python.cjs create mode 100644 desktop/test/agent-menu.test.cjs create mode 100644 desktop/test/core-files.test.cjs create mode 100644 desktop/test/macos-setup-integration.cjs create mode 100644 desktop/test/macos.test.cjs create mode 100644 desktop/test/macos_shell_smoke.py diff --git a/app.py b/app.py index 15d7c3e..396aeab 100644 --- a/app.py +++ b/app.py @@ -1972,11 +1972,14 @@ def get_default_local_shell_config(): shell = os.environ.get('SHELL') or '/bin/sh' terminal_kind = get_shell_kind(shell) + shell_command = [shell] + if sys.platform == 'darwin' and app.config.get('DESKTOP_LOGIN_SHELL') and terminal_kind != 'shell': + shell_command.append('-l') return { 'shell_kind': terminal_kind, 'terminal_kind': terminal_kind, 'terminal_label': get_shell_label(terminal_kind), - 'shell_command': [shell], + 'shell_command': shell_command, 'shell_display': shell, }, None @@ -6850,6 +6853,22 @@ def build_external_agentinfo_payload(base_url=None, agentinfo_path=None): command_endpoint = command_base_url.rstrip('/') + '/agent/external/command' handoff_path = EXTERNAL_AGENT_HANDOFF_PATH terminal_handoffs = build_external_agent_terminal_handoff_index() + skills = {} + for name, directory in ( + ('standterm-external-agent', 'standterm-external-agent-skill'), + ('standterm-file-transfer', 'standterm-file-transfer'), + ('standterm-privileged-hitl', 'standterm-privileged-hitl'), + ): + skill_dir = APP_DIR / 'docs' / 'examples' / directory + paths = { + 'path': skill_dir / 'SKILL.md', + 'boot_prompt_path': skill_dir / 'boot_prompt.txt', + 'install_prompt_path': skill_dir / 'skill_prompt.txt', + } + skills[name] = { + **{key: str(value) for key, value in paths.items()}, + 'available': all(value.is_file() for value in paths.values()), + } transport = { 'type': 'loopback_http_json', 'base_url': command_base_url, @@ -6864,6 +6883,7 @@ def build_external_agentinfo_payload(base_url=None, agentinfo_path=None): payload = { 'schema': 'standterm_agentinfo', 'schema_version': 1, + 'instance_id': LAUNCHER_INSTANCE_ID, 'protocol_version': EXTERNAL_AGENT_PROTOCOL_VERSION, 'generated_at': time.time(), 'base_url': command_base_url, @@ -6888,11 +6908,11 @@ def build_external_agentinfo_payload(base_url=None, agentinfo_path=None): 'agent_scp': str(APP_DIR / 'scripts' / 'agent_scp.py'), 'agent_shcmd': str(APP_DIR / 'scripts' / 'agent_shcmd.py'), 'agent_type': str(APP_DIR / 'scripts' / 'agent_type.py'), + 'agent_rsfile': str(APP_DIR / 'scripts' / 'agent_rsfile.py'), + 'agent_mcp': str(APP_DIR / 'scripts' / 'agent_mcp.py'), }, - 'skill': { - 'path': str(APP_DIR / 'docs' / 'examples' / 'standterm-external-agent-skill' / 'SKILL.md'), - 'boot_prompt_path': str(APP_DIR / 'docs' / 'examples' / 'standterm-external-agent-skill' / 'boot_prompt.txt'), - }, + 'skill': skills['standterm-external-agent'], + 'skills': skills, 'capabilities': list(EXTERNAL_AGENT_CAPABILITIES), 'recommended_commands': build_external_agentinfo_recommended_commands( agentinfo_path=agentinfo_path, diff --git a/desktop/README.md b/desktop/README.md index 40b0d70..0f3d949 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -2,9 +2,66 @@ This Electron evaluation owns a Python backend and opens the existing StandTerm UI without asking the local operator to paste an access token. It supports a -source-run workflow and an unsigned Windows x64 evaluation installer. It is not +source-run workflow, an unsigned Windows x64 evaluation installer and a native +Apple Silicon macOS evaluation app/DMG. It is not a production release or a replacement for `run.sh` / `run.bat`. +## macOS Apple Silicon evaluation + +The arm64 DMG contains `StandTermDesktop.app`, Electron and the verified Core +snapshot. Copy the app to a user-owned Applications folder before launching it. +The evaluation uses ad-hoc signing, without Developer ID or notarization; it is +not a Gatekeeper-qualified public release. No signing account or private key is +needed for a local build. Intel/Rosetta acceptance is not implied. + +Native macOS mode is selected automatically, or explicitly with `--backend=macos`. +Local shells start as login shells so their usual profiles (for example +`~/.zprofile` for zsh) supply MacPorts/Homebrew and user command paths even when +the app starts from Finder. The browser/Core launcher's shell behavior is unchanged. +First launch locates an existing native Python 3.10+ with venv/ensurepip support +in MacPorts, Homebrew or PATH, or offers a file chooser. Python must match the +app architecture. The Apple `/usr/bin/python3` developer-tools stub is skipped; +StandTerm does not install Python, package managers, Rosetta or OS components. + +After confirmation, setup copies Core and prepares its venv under +`~/Library/Application Support/StandTermDesktop/runtimes//`. +The interpreter is `tools/.venv_macos/bin/python`. Launcher settings, saved port, +diagnostics and the browser profile live under +`~/Library/Application Support/StandTermDesktopEvaluation/macos/`. +All mutable state stays outside the `.app`. Later launches verify and reuse the +runtime. Setup cancellation retains partial files for retry; a changed Core +bundle uses a new directory. Removing the app retains user data and runtimes. +The Windows installer's optional cleanup is not a macOS uninstall action. + +Build on an Apple Silicon Mac with Node 22.12+ and the checkout's macOS venv: + +```sh +cd desktop +npm ci +npm run stage:mac +# Change to the absolute stage directory printed above, then: +npm ci +npm run make:mac +``` + +Staging shares the tracked-file allowlist used by Windows, excludes internal +documents and Git state, and generates the native icon with macOS `sips` and +`iconutil`. macOS outputs are in the stage's `out.noindex/` directory so local +development app copies stay out of Spotlight results. The current build uses +electron-builder 26's [macOS signing options](https://www.electron.build/v26/docs/mac/). + +For isolated verification, run `npm test`, `npm run smoke:capture`, the Python +bootstrap/runtime/backend tests, and `test/browser-storage-smoke.cjs` with the +checkout Electron executable. Set separate `STANDTERM_AGENT_RUNTIME_DIR` and +`STANDTERM_SESSION_RECOVERY_STORE` paths for smoke runs. Use a canonical macOS +temporary root, for example `TMPDIR=/private/tmp`, for the complete Core suite. +`test/macos-setup-integration.cjs`, launched by checkout Electron with the prepared +venv Python and stage path as arguments, exercises the real first-run progress +window and bootstrap in a disposable runtime. Its setup choice is scripted. +Smoke camera/microphone denial probes use synthetic media devices, keeping them +independent of the operator's physical audio/video hardware; permission checks +remain enabled. Real device/OS permission behavior still needs manual acceptance. + ## Windows x64 evaluation installer The installer includes Electron and a SHA-256-manifested snapshot of the public @@ -152,7 +209,7 @@ that uncommitted changes are already a GitHub release. Build staging never copie the development venv or `node_modules`. The new directory gets Windows build dependencies; the source checkout's Linux/WSLg `node_modules` is untouched. -The `StandTerm-Desktop-0.4.1-win32-x64-Setup.exe` is under `out/`; the unpacked +The `StandTerm-Desktop-0.4.3-win32-x64-Setup.exe` is under `out/`; the unpacked application is under `out/win-unpacked/`. Packaging uses [electron-builder's assisted NSIS target](https://www.electron.build/nsis.html), with pinned build dependencies and scoped custom installer hooks. Squirrel @@ -334,6 +391,37 @@ cookies, Node access, preload or IPC bridge. A one-use main-process grant select only the existing StandTerm main frame; it never enumerates desktop windows or grants camera/microphone access. The terminal page cannot initiate capture. +## Agent prompts and bundled Core + +The top-level **Agent** menu is the first-use entrypoint; no prior StandTerm +skill installation is required. Choose **Copy skill installation prompt** and +paste it into your agent. For subsequent sessions, use **Copy usage prompt** or +**Copy file-transfer prompt** and describe the intended task and terminal. +**Getting started...** explains token minting and the distinction between setup +and terminal authority. **Copy connection info (JSON)** and **Copy agentinfo URL** +remain available for clients that already know the protocol. Existing Diagnostics +copy actions are preserved. + +These actions only copy text or show help. They do not install skills, execute +helpers, mint tokens or approve transfers. Each prompt contains the exact live +endpoint and instance ID, never credentials. Agents verify `/agentinfo` identity +and use its `launch_dir`, `python_path`, `scripts` and `skills` paths. Installation +prompts ask agents to preserve references and customized skills; agents without +persistent skill support can read the documents for the current session instead. +Backend paths belong to the active macOS, Windows or WSL environment. If access fails or +an older Core lacks discovery metadata, request the correct environment or a +Core update; do not guess a different endpoint or download arbitrary helpers. + +Core staging includes the published runtime, static assets, launch/install +scripts, README, public documentation, skill prompts/references and support +helpers. Only Git-tracked release inputs are selected; developer venvs, private +handoffs, profiles, credentials and unpublished rescue tools are not included. +`core-files.cjs` checks required inputs and relative skill links before and after +staging. The extracted-package inspector repeats the checks alongside manifest +hash verification, so missing skill documents or helpers fail packaging checks. +The Agent menu and expanded Core payload require a new installer build; existing +0.4.1 installers do not gain them automatically. + ## Authentication and security boundary **StandTerm > About StandTerm Desktop** lists Desktop and the running Core @@ -342,9 +430,11 @@ managed Core bundle SHA-256 identity when available. The same Core details are in Diagnostics. Core reports its version from `core_version.py`, independently of the Electron package version. Source checkouts have no managed build identity; older backends that omit version metadata show Unknown, never an inferred Git -tag. The current source candidate is Desktop 0.4.1 / Core 2.11.0-dev, not a -published stable release. Version and copy-info additions postdate the delivered -0.4.0 installer; they require a new build. +tag. The current source candidate is Desktop 0.4.3 / Core 2.11.0-dev, not a +published stable release. The Agent menu and expanded Core payload postdate the +published 0.4.1 installer and the earlier macOS 0.4.2 candidate; they require a +new build. The integrated macOS candidate retains native setup, login shells +and arm64 DMG packaging alongside these additions. The native **Diagnostics** menu shows the actual backend URL (IP and port), backend mode, Desktop version and web-settings storage mode. It opens diff --git a/desktop/agent-menu.cjs b/desktop/agent-menu.cjs new file mode 100644 index 0000000..d51bc11 --- /dev/null +++ b/desktop/agent-menu.cjs @@ -0,0 +1,45 @@ +'use strict'; + +const { agentConnectionInfo } = require('./diagnostics.cjs'); + +const INTRO = 'Read the agentinfo_url in the connection JSON below and verify instance_id before proceeding. ' + + 'Use launch_dir, python_path, scripts and skill/skills from that exact instance; do not guess ports or runtime paths. ' + + 'Read the bundled skill entrypoint and only the references needed for this task. ' + + 'Paths belong to the backend environment (macOS, Windows or WSL): if access or the endpoint is unavailable, report the limitation ' + + 'and ask for the correct environment or fresh connection information instead of scanning for another instance. '; +const BOUNDARY = 'Never expose handoff secrets or credentials. Skill installation is not permission to operate terminals. ' + + 'Preserve token minting, terminal modes, human-input boundaries and fresh per-copy browser approval. ' + + 'If no terminal is enabled, ask the user to enable External Agent and mint its token in the intended tab. ' + + 'Do not execute terminal commands until the user specifies the task and target.'; +const PROMPTS = { + usage: 'Help me use this StandTerm instance for my stated task. ' + INTRO + + 'Use the skill boot_prompt_path for routine operation; do not install or overwrite local skills in this workflow. ', + install: 'Help me install or update the bundled StandTerm skills for this agent. ' + INTRO + + 'Read each available skill install_prompt_path (skill_prompt.txt), including external-agent, file-transfer and privileged-HITL guidance. ' + + 'Use this agent\'s supported skill installation mechanism, preserving references and relative paths. ' + + 'Compare existing installations first and ask before overwriting customized content. ' + + 'If persistent skills are unsupported, read the documents for this session and report that nothing was installed. ' + + 'Use helpers from the active Core with its reported Python, rather than copying them into the skill installation. ', + transfer: 'Help me prepare a StandTerm file transfer. ' + INTRO + + 'Read the external-agent and file-transfer skills. Ask for any missing source, destination and file details. ' + + 'Prefer the typed backend copy helper; do not automate the human Files UI or approve the copy yourself. ' + + 'Do not fall back to terminal-stream rescue without a new explicit instruction. ', +}; + +function agentMenu({ origin, mode, instanceId, copyText, showHelp }) { + const info = agentConnectionInfo({ origin, mode, instanceId }); + const json = JSON.stringify(info, null, 2); + const prompt = kind => PROMPTS[kind] + BOUNDARY + '\n\n' + json; + return { id: 'agent-menu', label: 'Agent', submenu: [ + { id: 'agent-help', label: 'Getting started...', click: showHelp }, + { type: 'separator' }, + { id: 'agent-copy-usage', label: 'Copy usage prompt', click: () => copyText(prompt('usage')) }, + { id: 'agent-copy-install', label: 'Copy skill installation prompt', click: () => copyText(prompt('install')) }, + { id: 'agent-copy-transfer', label: 'Copy file-transfer prompt', click: () => copyText(prompt('transfer')) }, + { type: 'separator' }, + { id: 'agent-copy-connection', label: 'Copy connection info (JSON)', click: () => copyText(json) }, + { id: 'agent-copy-agentinfo', label: 'Copy agentinfo URL', click: () => copyText(info.agentinfo_url) }, + ] }; +} + +module.exports = { agentMenu }; diff --git a/desktop/backend.py b/desktop/backend.py index 0bd47ec..1232b88 100644 --- a/desktop/backend.py +++ b/desktop/backend.py @@ -56,6 +56,9 @@ def main(): sys.argv = [str(root / 'app.py')] import app as standterm standterm.app.config['DESKTOP_FLOATING_WINDOWS'] = True + # Finder starts apps without the login-shell environment used by Terminal. + # Let each local shell load its own profile (MacPorts/Homebrew/user PATH). + standterm.app.config['DESKTOP_LOGIN_SHELL'] = sys.platform == 'darwin' from server_startup import address_in_use, bound_server, suggested_port # Bind before sharing credentials. The parent decides whether to retry a diff --git a/desktop/bootstrap.py b/desktop/bootstrap.py index 48d5a5c..16b5355 100644 --- a/desktop/bootstrap.py +++ b/desktop/bootstrap.py @@ -39,8 +39,7 @@ def linked(path): def environment_python(root): - return (root / 'tools' / '.venv_win' / 'Scripts' / 'python.exe' if WINDOWS - else root / 'tools' / '.venv_wsl' / 'bin' / 'python') + return runtime.venv_path(root) / ('Scripts/python.exe' if WINDOWS else 'bin/python') def stop_child(process): diff --git a/desktop/browser-session.cjs b/desktop/browser-session.cjs index 4572c8d..c285301 100644 --- a/desktop/browser-session.cjs +++ b/desktop/browser-session.cjs @@ -3,7 +3,7 @@ const { randomUUID } = require('node:crypto'); function browserSessionOptions(mode, temporary = false) { - if (!['windows', 'wsl'].includes(mode)) throw new Error('Invalid browser profile mode.'); + if (!['windows', 'wsl', 'macos'].includes(mode)) throw new Error('Invalid browser profile mode.'); return { partition: temporary ? `standterm-test-${randomUUID()}` : `persist:standterm-ui-${mode}-v1`, options: { cache: false }, diff --git a/desktop/core-files.cjs b/desktop/core-files.cjs new file mode 100644 index 0000000..52f3fc1 --- /dev/null +++ b/desktop/core-files.cjs @@ -0,0 +1,42 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const SKILL_DIRS = ['standterm-external-agent-skill', 'standterm-file-transfer', 'standterm-privileged-hitl']; +const REQUIRED = ['app.py', 'core_version.py', 'requirements.txt', 'README.md', 'run.sh', 'run.bat', + 'run.command', 'run_at_wsl.bat', 'run_at_wsl+screen.bat', 'install.sh', 'install.ps1', 'install.command', + 'LICENSE', 'THIRD-PARTY-NOTICES.md', 'desktop/backend.py', 'desktop/runtime.py', 'docs/agent_socket_contract.md', + 'docs/backend_plugin_contract.md', 'docs/venv_prompt.txt', + ...['cli', 'jsonl', 'repl', 'shcmd', 'scp', 'type', 'rsfile', 'mcp'].map(name => `scripts/agent_${name}.py`), + ...SKILL_DIRS.flatMap(name => ['SKILL.md', 'boot_prompt.txt', 'skill_prompt.txt'] + .map(file => `docs/examples/${name}/${file}`)), + ...['connection', 'clients', 'terminal-workflows'] + .map(name => `docs/examples/standterm-external-agent-skill/references/${name}.md`), +]; + +function coreFiles(tracked) { + return tracked.filter(file => /^[^/]+\.py$/.test(file) + || /^(static|templates|terminal_backends|scripts)\//.test(file) + || REQUIRED.includes(file)).sort(); +} + +function validateCoreFiles(root, files) { + const selected = new Set(files); + for (const file of REQUIRED) { + if (!selected.has(file)) throw new Error(`Missing required Core input: ${file}`); + } + for (const file of files) { + if (!fs.lstatSync(path.join(root, file)).isFile()) throw new Error(`Not a regular Core input: ${file}`); + if (!file.startsWith('docs/examples/') || !file.endsWith('.md')) continue; + const text = fs.readFileSync(path.join(root, file), 'utf8'); + for (const match of text.matchAll(/\[[^\]]*\]\(([^\s)]+)\)/g)) { + const link = match[1].split('#')[0]; + if (!link || /^[a-z][a-z0-9+.-]*:/i.test(link)) continue; + const target = path.posix.normalize(path.posix.join(path.posix.dirname(file), link)); + if (!selected.has(target)) throw new Error(`Missing bundled skill reference: ${file} -> ${link}`); + } + } +} + +module.exports = { coreFiles, validateCoreFiles, REQUIRED }; diff --git a/desktop/desktop-mode.cjs b/desktop/desktop-mode.cjs index 7d91c56..910f686 100644 --- a/desktop/desktop-mode.cjs +++ b/desktop/desktop-mode.cjs @@ -1,14 +1,19 @@ 'use strict'; -const MODES = Object.freeze({ windows: 'StandTerm Desktop', wsl: 'StandTerm Desktop (WSL)' }); +const WINDOWS_MODES = Object.freeze({ windows: 'StandTerm Desktop', wsl: 'StandTerm Desktop (WSL)' }); +const MODES = Object.freeze({ ...WINDOWS_MODES, macos: 'StandTerm Desktop' }); const APP_ID = 'com.squirrel.StandTermDesktopEvaluation.StandTermDesktopEvaluation'; -function desktopMode(argv) { +function desktopMode(argv, platform = process.platform) { const values = argv.filter(value => value.startsWith('--backend=')); if (values.length > 1 || (values.length && !Object.hasOwn(MODES, values[0].slice(10)))) { - throw new Error('Use exactly one supported backend: --backend=windows or --backend=wsl.'); + throw new Error('Use exactly one supported backend: --backend=windows, --backend=wsl or --backend=macos.'); } - return values.length ? values[0].slice(10) : 'windows'; + const mode = values.length ? values[0].slice(10) : platform === 'darwin' ? 'macos' : 'windows'; + if ((platform === 'darwin' && mode !== 'macos') || (platform !== 'darwin' && mode === 'macos')) { + throw new Error('The macOS backend requires native macOS; Windows and WSL modes require Windows.'); + } + return mode; } -module.exports = { MODES, APP_ID, desktopMode }; +module.exports = { MODES, WINDOWS_MODES, APP_ID, desktopMode }; diff --git a/desktop/diagnostics.cjs b/desktop/diagnostics.cjs index d4b31e7..0163ae3 100644 --- a/desktop/diagnostics.cjs +++ b/desktop/diagnostics.cjs @@ -17,7 +17,7 @@ function createDiagnostics(directory, { mode, version }) { function write(event, details = {}) { if (!EVENTS.has(event)) return; const record = { time: new Date().toISOString(), event, - mode: mode === 'wsl' ? 'wsl' : 'windows' }; + mode: ['wsl', 'macos'].includes(mode) ? mode : 'windows' }; if (/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/.test(version)) record.version = version; // Whitelist structured fields. Never persist errors, stdout/stderr, URLs, // terminal data, environment values, credential objects or arbitrary text. @@ -43,7 +43,7 @@ function agentConnectionInfo({ origin, mode, instanceId }) { const url = new URL(origin); if (url.origin !== origin || url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port || url.username || url.password) throw new Error('Invalid diagnostic origin.'); - if (!['windows', 'wsl'].includes(mode) || typeof instanceId !== 'string' + if (!['windows', 'wsl', 'macos'].includes(mode) || typeof instanceId !== 'string' || !/^[A-Za-z0-9_-]{1,256}$/.test(instanceId)) throw new Error('Invalid connection identity.'); return { schema: 'standterm_agent_connection', schema_version: 1, base_url: origin, agentinfo_url: `${origin}/agentinfo`, instance_id: instanceId, backend_mode: mode }; diff --git a/desktop/electron-builder.cjs b/desktop/electron-builder.cjs index 4f0bbbc..7398439 100644 --- a/desktop/electron-builder.cjs +++ b/desktop/electron-builder.cjs @@ -4,11 +4,22 @@ module.exports = { appId: 'org.standterm.desktop', productName: 'StandTerm Desktop', executableName: 'StandTermDesktop', - directories: { output: 'out', buildResources: '.' }, + // Keep development app copies out of macOS Spotlight application results. + directories: { output: process.platform === 'darwin' ? 'out.noindex' : 'out', buildResources: '.' }, asar: true, files: ['*.cjs', '*.html', 'recorder.js', 'package.json', 'README.md', 'LICENSE', 'test/capture-smoke.cjs', 'test/floating-smoke.cjs', 'test/external-links-smoke.cjs', '!electron-builder.cjs', '!stage-windows.cjs', '!build-icon.cjs'], extraResources: [{ from: 'bundle', to: 'bundle' }], + mac: { + icon: 'standterm.icns', + target: [{ target: 'dmg', arch: ['arm64'] }], + category: 'public.app-category.developer-tools', + artifactName: 'StandTerm-Desktop-${version}-mac-${arch}.${ext}', + // Local evaluation only: ad-hoc signing never reads a Developer ID identity. + identity: '-', + notarize: false, + }, + dmg: { sign: false }, win: { target: [{ target: 'nsis', arch: ['x64'] }], icon: 'standterm.ico', artifactName: 'StandTerm-Desktop-${version}-win32-x64-Setup.exe' }, nsis: { diff --git a/desktop/installer-shortcuts.cjs b/desktop/installer-shortcuts.cjs index 4c49f4b..b30c3bc 100644 --- a/desktop/installer-shortcuts.cjs +++ b/desktop/installer-shortcuts.cjs @@ -2,7 +2,7 @@ const fs = require('node:fs'); const path = require('node:path'); -const { MODES, APP_ID } = require('./desktop-mode.cjs'); +const { WINDOWS_MODES: MODES, APP_ID } = require('./desktop-mode.cjs'); function shortcutPlan(executable, desktop, programs, selected) { if (selected.some(mode => !Object.hasOwn(MODES, mode))) throw new Error('Invalid shortcut mode.'); diff --git a/desktop/macos-python.cjs b/desktop/macos-python.cjs new file mode 100644 index 0000000..e9df23b --- /dev/null +++ b/desktop/macos-python.cjs @@ -0,0 +1,28 @@ +'use strict'; + +const path = require('node:path'); + +const MACOS_HELP = 'Install native macOS Python 3.10+ with venv and ensurepip support first.\n\n' + + 'Python must match this app’s CPU architecture. StandTerm checks Homebrew, MacPorts and PATH, ' + + 'or lets you select an installed interpreter. Apple’s /usr/bin/python3 developer-tools stub is not launched. ' + + 'StandTerm does not install Python, Homebrew, Rosetta or system packages.'; + +function macPythonCandidates(saved, env = {}) { + // Finder launch has a minimal PATH; known package-manager locations remain usable. + return [...new Set([saved, '/opt/homebrew/bin/python3', '/opt/local/bin/python3', '/usr/local/bin/python3', + ...(env.PATH || '').split(':').filter(directory => path.posix.isAbsolute(directory)) + .map(directory => path.posix.join(directory, 'python3'))])] + .filter(candidate => typeof candidate === 'string' && path.posix.isAbsolute(candidate) + && candidate !== '/usr/bin/python3' && !/[\r\n\0]/.test(candidate)).slice(0, 16); +} + +function validMacPython(info, arch) { + if (!['arm64', 'x64'].includes(arch)) return false; + return info?.type === 'python_info' && info.platform === 'darwin' && info.bits === 64 && info.venv === true + && Array.isArray(info.version) && info.version[0] === 3 && Number.isInteger(info.version[1]) && info.version[1] >= 10 + && ({ arm64: 'arm64', x64: 'x86_64' })[arch] === info.machine + && typeof info.executable === 'string' && path.posix.isAbsolute(info.executable) + && info.executable !== '/usr/bin/python3' && !/[\r\n\0]/.test(info.executable); +} + +module.exports = { MACOS_HELP, macPythonCandidates, validMacPython }; diff --git a/desktop/main.cjs b/desktop/main.cjs index fe039bc..1e57b3a 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -15,6 +15,7 @@ const { startWithPort, parsePortConflict, checkHostPort } = require('./port.cjs' const { installerRequest, runInstaller } = require('./installer.cjs'); const { installFloatingWindows } = require('./floating-windows.cjs'); const { createDiagnostics, diagnosticsMenu, agentConnectionInfo, openDeveloperTools } = require('./diagnostics.cjs'); +const { agentMenu } = require('./agent-menu.cjs'); const { browserSessionOptions, resetBrowserAuthentication } = require('./browser-session.cjs'); const { createStatusWindow } = require('./diagnostics-window.cjs'); const { createExternalOpener } = require('./external-links.cjs'); @@ -55,6 +56,9 @@ if (process.platform === 'win32') app.setAppUserModelId(`${APP_ID}.${mode}`); app.enableSandbox(); const captureSmoke = process.argv.includes('--desktop-capture-smoke'); const smoke = process.argv.includes('--desktop-smoke') || captureSmoke; +// Denied camera/microphone probes must not enumerate the operator's real devices. +// This supplies synthetic devices, not permission grants or a fake chooser. +if (smoke) app.commandLine.appendSwitch('use-fake-device-for-media-stream'); // Tests must never focus an existing operator window through the instance lock. if (smoke) app.setPath('userData', fs.mkdtempSync(path.join(os.tmpdir(), 'standterm-desktop-test-'))); else if (app.isPackaged) { @@ -102,7 +106,7 @@ function launchBackend(preparedCommand, port = 0) { let buffer = ''; let received = false; const timer = setTimeout(() => reject(new Error( - 'Backend startup timed out. Check the selected Python environment and WSL availability.', + 'Backend startup timed out. Check the selected Python environment and backend availability.', )), 60000); child.once('error', error => { diagnostics.write('backend_spawn_failed', { code: error.code }); @@ -337,6 +341,19 @@ async function start() { { label: 'Quit StandTerm', accelerator: 'CommandOrControl+Q', click: () => app.quit() }, ] }, { role: 'editMenu' }, + agentMenu({ origin: handoff.origin, mode, instanceId: handoff.instance_id, + copyText: text => clipboard.writeText(text), + showHelp: () => dialog.showMessageBox(win, { + type: 'info', title: 'StandTerm Agent', message: 'Give your agent a StandTerm prompt', + detail: 'First time: choose Agent > Copy skill installation prompt and paste it into your agent. ' + + 'For an existing setup, use Copy usage prompt or Copy file-transfer prompt and describe your task.\n\n' + + 'Prompts identify this instance without credentials. The agent reads bundled Core documents and helpers; ' + + 'it needs access to the backend environment (macOS, Windows or WSL).\n\n' + + 'When ready, enable External Agent in the intended terminal tab and mint a token. ' + + 'Copying a prompt does not install anything, grant terminal access or approve transfers.', + buttons: ['OK'], noLink: true, + }), + }), capture.menu(), { label: 'View', submenu: [{ role: 'resetZoom' }, { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'togglefullscreen' }] }, diagnosticsMenu({ origin: handoff.origin, mode, instanceId: handoff.instance_id, diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 9c37823..d431ab2 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "standterm-desktop-evaluation", - "version": "0.4.1", + "version": "0.4.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "standterm-desktop-evaluation", - "version": "0.4.1", + "version": "0.4.3", "license": "MIT", "devDependencies": { "electron": "44.2.0", diff --git a/desktop/package.json b/desktop/package.json index 67d4023..03dddef 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "standterm-desktop-evaluation", - "version": "0.4.1", + "version": "0.4.3", "private": true, "productName": "StandTerm Desktop", "author": "ASKA C.", @@ -14,7 +14,9 @@ "smoke": "electron . --desktop-smoke", "smoke:capture": "electron . --desktop-capture-smoke", "stage:win": "node stage-windows.cjs", - "make:win": "electron-builder --config electron-builder.cjs --win nsis --x64 --publish never" + "make:win": "electron-builder --config electron-builder.cjs --win nsis --x64 --publish never", + "stage:mac": "node stage-windows.cjs --macos", + "make:mac": "electron-builder --config electron-builder.cjs --mac dmg --arm64 --publish never" }, "devDependencies": { "electron-builder": "26.15.3", diff --git a/desktop/runtime.py b/desktop/runtime.py index 1c1611b..94f4755 100644 --- a/desktop/runtime.py +++ b/desktop/runtime.py @@ -37,12 +37,15 @@ def read_marker(path): def runtime_base(): + if sys.platform == 'darwin': + return Path.home() / 'Library' / 'Application Support' / 'StandTermDesktop' return (Path(os.environ['LOCALAPPDATA']) / 'StandTermDesktop' if sys.platform == 'win32' else Path.home() / '.local' / 'share' / 'standterm-desktop') def venv_path(root): - return root / 'tools' / ('.venv_win' if sys.platform == 'win32' else '.venv_wsl') + name = '.venv_win' if sys.platform == 'win32' else '.venv_macos' if sys.platform == 'darwin' else '.venv_wsl' + return root / 'tools' / name @contextmanager diff --git a/desktop/setup.cjs b/desktop/setup.cjs index da9a3b5..b81dd71 100644 --- a/desktop/setup.cjs +++ b/desktop/setup.cjs @@ -5,6 +5,7 @@ const { spawn } = require('node:child_process'); const fs = require('node:fs/promises'); const path = require('node:path'); const { pathToFileURL } = require('node:url'); +const { macPythonCandidates, validMacPython, MACOS_HELP } = require('./macos-python.cjs'); const SETUP_URL = pathToFileURL(path.join(__dirname, 'setup.html')).href; const HELP = 'Install WSL and Python 3.10+ with venv support in the selected distribution first.\n\n' @@ -15,7 +16,7 @@ const WINDOWS_HELP = 'Install 64-bit Python 3.10+ with venv support on Windows f + 'are not launched automatically. No system Python installation or administrator access is requested.'; const PYTHON_PROBE = 'import sys, struct, importlib.util, json; print(json.dumps({"type":"python_info",' + '"executable":sys.executable,"platform":sys.platform,"version":list(sys.version_info[:2]),' - + '"bits":struct.calcsize("P")*8,"venv":bool(importlib.util.find_spec("venv") and importlib.util.find_spec("ensurepip"))}))'; + + '"machine":__import__("platform").machine(),"bits":struct.calcsize("P")*8,"venv":bool(importlib.util.find_spec("venv") and importlib.util.find_spec("ensurepip"))}))'; const ERRORS = { python_required: HELP, venv_failed: `Python could not create the private venv.\n\n${HELP}`, @@ -41,7 +42,7 @@ function cancelSetup() { canceled = true; current?.cancelSetup?.(); } async function stopSetup() { cancelSetup(); await executionFinished; } function modeProfile(mode) { - if (!['windows', 'wsl'].includes(mode)) throw new Error('Invalid desktop mode.'); + if (!['windows', 'wsl', 'macos'].includes(mode)) throw new Error('Invalid desktop mode.'); return path.join(app.getPath('appData'), 'StandTermDesktopEvaluation', mode); } @@ -164,10 +165,33 @@ async function windowsPython(saved) { return probe(selected.filePaths[0]); } +async function macosPython(saved) { + async function probe(candidate) { + if (!path.posix.isAbsolute(candidate) || /[\r\n\0]/.test(candidate) || candidate === '/usr/bin/python3') { + throw new Error(MACOS_HELP); + } + const result = await execute(candidate, ['-I', '-c', PYTHON_PROBE], { stream: true, help: MACOS_HELP }); + if (!validMacPython(result, process.arch)) throw new Error(MACOS_HELP); + return result.executable; + } + for (const candidate of macPythonCandidates(saved, process.env)) { + try { return await probe(candidate); } catch { if (canceled) throw canceledError(); } + } + const answer = await dialog.showMessageBox({ type: 'info', title: 'StandTerm Desktop: Python required', + message: MACOS_HELP, buttons: ['Cancel', 'Select installed Python...'], defaultId: 0, cancelId: 0 }); + if (answer.response !== 1) throw canceledError(); + const selected = await dialog.showOpenDialog({ title: 'Select a native macOS Python 3.10+ interpreter', + properties: ['openFile'] }); + if (selected.canceled || selected.filePaths.length !== 1) throw canceledError(); + return probe(selected.filePaths[0]); +} + async function preparePackagedBackend(mode, { installer = false } = {}) { - if (!['windows', 'wsl'].includes(mode)) throw new Error('Choose a supported desktop backend.'); - const native = mode === 'windows'; - const help = native ? WINDOWS_HELP : HELP; + if (!['windows', 'wsl', 'macos'].includes(mode)) throw new Error('Choose a supported desktop backend.'); + const windows = mode === 'windows'; + const macos = mode === 'macos'; + const native = windows || macos; + const help = macos ? MACOS_HELP : windows ? WINDOWS_HELP : HELP; const bundle = path.join(process.resourcesPath, 'bundle'); const metadata = JSON.parse(await fs.readFile(path.join(bundle, 'manifest.json'), 'utf8')); if (!/^[a-f0-9]{64}$/.test(metadata.id)) throw new Error(ERRORS.invalid_bundle); @@ -182,7 +206,7 @@ async function preparePackagedBackend(mode, { installer = false } = {}) { let args; let saved; if (native) { - executable = await windowsPython(settings?.python); + executable = await (macos ? macosPython(settings?.python) : windowsPython(settings?.python)); args = ['-I', path.join(bundle, 'bootstrap.py'), '--bundle', bundle]; saved = { version: 1, python: executable }; } else { @@ -220,9 +244,9 @@ async function preparePackagedBackend(mode, { installer = false } = {}) { if (result.type === 'needs_setup') { const answer = await dialog.showMessageBox({ type: 'question', title: 'Prepare StandTerm Core', - message: `Create a private StandTerm environment in ${native ? 'Windows' : distro}?`, - detail: `Requires Python 3.10+ and venv support ${native ? 'on Windows (64-bit)' : 'inside WSL'}.\n\n` - + `This copies the bundled Core into ${native ? '%LOCALAPPDATA%\\StandTermDesktop\\runtimes\\' : '~/.local/share/standterm-desktop/runtimes/'}, creates its own venv, ` + message: `Create a private StandTerm environment in ${macos ? 'macOS' : windows ? 'Windows' : distro}?`, + detail: `Requires Python 3.10+ and venv support ${macos ? 'on native macOS' : windows ? 'on Windows (64-bit)' : 'inside WSL'}.\n\n` + + `This copies the bundled Core into ${macos ? '~/Library/Application Support/StandTermDesktop/runtimes/' : windows ? '%LOCALAPPDATA%\\StandTermDesktop\\runtimes\\' : '~/.local/share/standterm-desktop/runtimes/'}, creates its own venv, ` + 'and downloads and installs Python dependencies from your configured package index. ' + 'Dependencies can execute installation code. Internet access and disk space are required.\n\n' + 'No system Python installation, sudo, Git checkout changes or existing-session interruption. ' @@ -249,7 +273,7 @@ async function preparePackagedBackend(mode, { installer = false } = {}) { try { await window.loadURL(SETUP_URL); await window.webContents.executeJavaScript(`document.getElementById('requirements').textContent = ${JSON.stringify( - native ? 'Preparing Core for native Windows.' : `Preparing Core inside WSL: ${distro}.`)}`); + macos ? 'Preparing Core for native macOS.' : windows ? 'Preparing Core for native Windows.' : `Preparing Core inside WSL: ${distro}.`)}`); result = await execute(executable, [...args, '--prepare'], { stream: true, timeout: 30 * 60 * 1000, help, progress: stage => { const labels = { copy: 'Copying verified Core files...', venv: 'Creating the private Python environment...', dependencies: 'Installing Python dependencies. This can take several minutes...', verify: 'Verifying the installed dependencies...' }; @@ -264,11 +288,11 @@ async function preparePackagedBackend(mode, { installer = false } = {}) { setupFinished = null; } } - const runtimePath = native ? path.win32 : path.posix; + const runtimePath = windows ? path.win32 : path.posix; if (result.type !== 'ready' || result.bundle_id !== metadata.id || typeof result.root !== 'string' || !runtimePath.isAbsolute(result.root) - || result.python !== (native ? runtimePath.join(result.root, 'tools', '.venv_win', 'Scripts', 'python.exe') - : `${result.root}/tools/.venv_wsl/bin/python`)) throw new Error('Invalid managed runtime response.'); + || result.python !== (windows ? runtimePath.join(result.root, 'tools', '.venv_win', 'Scripts', 'python.exe') + : runtimePath.join(result.root, 'tools', macos ? '.venv_macos' : '.venv_wsl', 'bin', 'python'))) throw new Error('Invalid managed runtime response.'); await fs.mkdir(path.dirname(settingsPath), { recursive: true }); // This file contains only non-secret launcher metadata, never credentials. const temporary = `${settingsPath}.tmp`; @@ -280,6 +304,7 @@ async function preparePackagedBackend(mode, { installer = false } = {}) { } async function cleanupManagedVenvs(mode) { + if (!['windows', 'wsl'].includes(mode)) throw new Error('Environment cleanup is available through the Windows installer only.'); // Only the configured interpreter/distribution is considered. Never discover // other projects or provision a WSL distribution during uninstallation. const settingsPath = path.join(modeProfile(mode), 'launcher.json'); diff --git a/desktop/smoke.cjs b/desktop/smoke.cjs index 0b48870..31abe7c 100644 --- a/desktop/smoke.cjs +++ b/desktop/smoke.cjs @@ -32,13 +32,35 @@ async function run(win, origin) { try { menu.getMenuItemById('diagnostics-copy-origin').click(); menu.getMenuItemById('diagnostics-copy-agent').click(); + menu.getMenuItemById('agent-copy-connection').click(); + menu.getMenuItemById('agent-copy-install').click(); + menu.getMenuItemById('agent-copy-usage').click(); + menu.getMenuItemById('agent-copy-transfer').click(); } finally { clipboard.writeText = originalCopy; } assert.equal(copied[0], origin); const connection = JSON.parse(copied[1]); + assert.equal(copied[2], copied[1]); + for (const prompt of copied.slice(3)) { + assert.deepEqual(JSON.parse(prompt.slice(prompt.indexOf('{'))), connection); + assert.ok(prompt.includes('verify instance_id')); + } assert.equal(connection.base_url, origin); assert.equal(connection.agentinfo_url, origin + '/agentinfo'); assert.ok(connection.instance_id); - assert.ok(['windows', 'wsl'].includes(connection.backend_mode)); + const agentinfo = await win.webContents.executeJavaScript('fetch("/agentinfo").then(response => response.json())'); + assert.equal(agentinfo.instance_id, connection.instance_id); + assert.equal(agentinfo.agentinfo_url, connection.agentinfo_url); + for (const name of ['standterm-external-agent', 'standterm-file-transfer', 'standterm-privileged-hitl']) { + const skill = agentinfo.skills[name]; + assert.equal(skill.available, true, `Missing ${name} in the running Core`); + for (const field of ['path', 'boot_prompt_path', 'install_prompt_path']) { + assert.equal(typeof skill[field], 'string'); + assert.ok(skill[field].length); + } + } + assert.deepEqual(agentinfo.skill, agentinfo.skills['standterm-external-agent']); + for (const name of ['agent_scp', 'agent_rsfile', 'agent_mcp']) assert.equal(typeof agentinfo.scripts[name], 'string'); + assert.ok(['windows', 'wsl', 'macos'].includes(connection.backend_mode)); assert.deepEqual(Object.keys(connection).sort(), ['schema', 'schema_version', 'base_url', 'agentinfo_url', 'instance_id', 'backend_mode'].sort()); assert.equal(win.webContents.isDevToolsOpened(), false); diff --git a/desktop/squirrel-events.cjs b/desktop/squirrel-events.cjs index cf649bc..1eac5ac 100644 --- a/desktop/squirrel-events.cjs +++ b/desktop/squirrel-events.cjs @@ -2,7 +2,7 @@ const fs = require('node:fs'); const path = require('node:path'); -const { MODES, APP_ID } = require('./desktop-mode.cjs'); +const { WINDOWS_MODES: MODES, APP_ID } = require('./desktop-mode.cjs'); const EVENTS = new Set(['--squirrel-install', '--squirrel-updated', '--squirrel-uninstall', '--squirrel-obsolete']); function isSquirrelEvent(argv) { return EVENTS.has(argv[1]); } diff --git a/desktop/stage-windows.cjs b/desktop/stage-windows.cjs index 1e5b5e2..31f8007 100644 --- a/desktop/stage-windows.cjs +++ b/desktop/stage-windows.cjs @@ -6,30 +6,29 @@ const path = require('node:path'); const { execFileSync } = require('node:child_process'); const { createHash } = require('node:crypto'); const { writeIcon } = require('./build-icon.cjs'); +const { coreFiles: selectCoreFiles, validateCoreFiles } = require('./core-files.cjs'); +const platform = process.argv.includes('--macos') ? 'macos' : 'windows'; +if (platform === 'macos' && process.platform !== 'darwin') throw new Error('Stage macOS on a native Mac.'); const root = path.resolve(__dirname, '..'); const tracked = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8' }).split('\0').filter(Boolean); -const coreFiles = tracked.filter(file => /^[^/]+\.py$/.test(file) - || /^(static|templates|terminal_backends|scripts)\//.test(file) - || ['requirements.txt', 'LICENSE', 'THIRD-PARTY-NOTICES.md', 'desktop/backend.py'].includes(file)); -// This new shared lifetime lease is required by the packaged backend even while -// awaiting its first Git commit. Never include arbitrary untracked Core files. -if (!coreFiles.includes('desktop/runtime.py')) coreFiles.push('desktop/runtime.py'); +const coreFiles = selectCoreFiles(tracked); +validateCoreFiles(root, coreFiles); const shellFiles = [ 'package.json', 'package-lock.json', 'electron-builder.cjs', 'installer.nsh', 'main.cjs', 'policy.cjs', - 'capture.cjs', 'capture-file.cjs', 'recorder.html', 'recorder.js', 'setup.cjs', + 'capture.cjs', 'capture-file.cjs', 'recorder.html', 'recorder.js', 'setup.cjs', 'macos-python.cjs', 'setup.html', 'README.md', 'smoke.cjs', 'test/capture-smoke.cjs', 'desktop-mode.cjs', 'squirrel-events.cjs', 'port.cjs', 'installer.cjs', 'installer-shortcuts.cjs', 'legacy-install.nsh', 'floating-windows.cjs', 'test/floating-smoke.cjs', - 'diagnostics.cjs', + 'diagnostics.cjs', 'agent-menu.cjs', 'browser-session.cjs', 'diagnostics-window.cjs', 'external-links.cjs', 'test/external-links-smoke.cjs', ]; fs.mkdirSync(path.join(__dirname, 'dist'), { recursive: true }); -const stage = fs.mkdtempSync(path.join(__dirname, 'dist', 'windows-build-')); +const stage = fs.mkdtempSync(path.join(__dirname, 'dist', `${platform}-build-`)); function copy(source, destination) { if (!fs.lstatSync(source).isFile()) throw new Error(`Not a regular input file: ${source}`); fs.mkdirSync(path.dirname(destination), { recursive: true }); @@ -42,11 +41,25 @@ copy(path.join(__dirname, 'windows_job.py'), path.join(stage, 'bundle', 'windows copy(path.join(__dirname, 'runtime.py'), path.join(stage, 'bundle', 'runtime.py')); copy(path.join(__dirname, 'runtime_cleanup.py'), path.join(stage, 'bundle', 'runtime_cleanup.py')); writeIcon(path.join(stage, 'standterm.ico')); +if (platform === 'macos') { + // Reuse the existing terminal glyph at native icon sizes using macOS build tools. + const iconset = path.join(stage, 'standterm.iconset'); + fs.mkdirSync(iconset); + for (const size of [16, 32, 128, 256, 512]) { + for (const scale of [1, 2]) { + execFileSync('/usr/bin/sips', ['-s', 'format', 'png', '-z', String(size * scale), String(size * scale), + path.join(stage, 'standterm.ico'), '--out', path.join(iconset, `icon_${size}x${size}${scale === 2 ? '@2x' : ''}.png`)], + { stdio: 'pipe' }); + } + } + execFileSync('/usr/bin/iconutil', ['-c', 'icns', iconset, '-o', path.join(stage, 'standterm.icns')]); +} const files = {}; for (const file of coreFiles.sort()) { copy(path.join(root, file), path.join(stage, 'bundle', 'core', file)); files[file] = createHash('sha256').update(fs.readFileSync(path.join(root, file))).digest('hex'); } const id = createHash('sha256').update(JSON.stringify(files)).digest('hex'); +validateCoreFiles(path.join(stage, 'bundle', 'core'), Object.keys(files)); fs.writeFileSync(path.join(stage, 'bundle', 'manifest.json'), JSON.stringify({ version: 1, id, files }, null, 2), { flag: 'wx' }); console.log(stage); diff --git a/desktop/test/agent-menu.test.cjs b/desktop/test/agent-menu.test.cjs new file mode 100644 index 0000000..844ab1b --- /dev/null +++ b/desktop/test/agent-menu.test.cjs @@ -0,0 +1,46 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { agentMenu } = require('../agent-menu.cjs'); +const { agentConnectionInfo } = require('../diagnostics.cjs'); + +for (const mode of ['windows', 'wsl', 'macos']) { + test(`Agent menu offers opt-in prompts for the exact ${mode} instance`, () => { + const copied = []; + let help = 0; + const options = { origin: 'http://127.0.0.1:64487', mode, instanceId: 'instance-test', + token: 'SECRET-TEST', terminal: 'PRIVATE-TEST', copyText: text => copied.push(text), showHelp: () => help++ }; + const menu = agentMenu(options); + assert.equal(menu.label, 'Agent'); + assert.deepEqual(copied, []); + const click = id => menu.submenu.find(item => item.id === id).click(); + click('agent-help'); + assert.equal(help, 1); + assert.deepEqual(copied, []); + for (const kind of ['usage', 'install', 'transfer']) { + click(`agent-copy-${kind}`); + const value = copied.at(-1); + assert.deepEqual(JSON.parse(value.slice(value.indexOf('{'))), agentConnectionInfo(options)); + for (const required of ['verify instance_id', 'python_path', 'launch_dir', 'macOS, Windows or WSL', + 'do not guess ports', 'mint', 'approval', 'Do not execute terminal commands']) assert.ok(value.includes(required)); + assert.ok(!/SECRET-TEST|PRIVATE-TEST|:5000/.test(value)); + } + assert.ok(copied[0].includes('do not install or overwrite')); + assert.ok(copied[1].includes('ask before overwriting customized content')); + assert.ok(copied[1].includes('nothing was installed')); + assert.ok(copied[2].includes('Do not fall back to terminal-stream rescue')); + click('agent-copy-connection'); + assert.deepEqual(JSON.parse(copied.at(-1)), agentConnectionInfo(options)); + click('agent-copy-agentinfo'); + assert.equal(copied.at(-1), options.origin + '/agentinfo'); + }); +} + +test('Agent menu rejects credential-bearing origins and invalid identities', () => { + const options = { origin: 'http://127.0.0.1:64487', mode: 'wsl', instanceId: 'instance-test' }; + for (const invalid of [{ origin: options.origin + '/?token=secret' }, { origin: 'https://example.com' }, + { instanceId: 'id\ncontrol' }, { mode: 'unknown' }]) { + assert.throws(() => agentMenu({ ...options, ...invalid })); + } +}); diff --git a/desktop/test/backend_smoke.py b/desktop/test/backend_smoke.py index 5a29dd5..73815c3 100644 --- a/desktop/test/backend_smoke.py +++ b/desktop/test/backend_smoke.py @@ -44,6 +44,16 @@ def main(): origin = frame['origin'] assert urllib.parse.urlparse(origin).hostname == '127.0.0.1' opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + with opener.open(origin + '/agentinfo', timeout=5) as response: + agentinfo = json.load(response) + assert agentinfo['instance_id'] == frame['instance_id'] + assert agentinfo['launch_dir'] == str(ROOT) + for skill in agentinfo['skills'].values(): + assert skill['available'] is True + for key in ('path', 'boot_prompt_path', 'install_prompt_path'): + assert Path(skill[key]).is_file() + for helper in agentinfo['scripts'].values(): + assert Path(helper).is_file() try: opener.open(origin, timeout=5) except urllib.error.HTTPError as exc: diff --git a/desktop/test/bootstrap_smoke.py b/desktop/test/bootstrap_smoke.py index b67fbb4..b13509a 100644 --- a/desktop/test/bootstrap_smoke.py +++ b/desktop/test/bootstrap_smoke.py @@ -18,7 +18,7 @@ class BootstrapTests(unittest.TestCase): def fixture(self, lease_aware=False): - root = Path(tempfile.mkdtemp(prefix='standterm-bootstrap-test-')) + root = Path(tempfile.mkdtemp(prefix='standterm-bootstrap-test-')).resolve() bundle = root / 'bundle' files = {} names = ['app.py', 'desktop/backend.py', 'requirements.txt'] @@ -147,8 +147,7 @@ def test_stubborn_process_group_is_killed(self): try: bootstrap.stop_child(child) self.assertIsNotNone(child.poll()) - state = Path(f'/proc/{descendant}/stat') - self.assertTrue(not state.exists() or state.read_text().split()[2] == 'Z') + self.assert_process_dead(descendant) finally: if child.poll() is None: bootstrap.stop_child(child) @@ -171,9 +170,14 @@ def test_exited_leader_does_not_hide_descendant(self): child.stdout.close() def assert_process_dead(self, pid): - state = Path(f'/proc/{pid}/stat') deadline = time.monotonic() + 5 - while state.exists() and state.read_text().split()[2] != 'Z': + while True: + # macOS has no /proc; inspect only the child PID owned by this test. + result = subprocess.run(['ps', '-p', str(pid), '-o', 'stat='], + capture_output=True, text=True, timeout=5) + if result.returncode == 1 or result.stdout.strip().startswith('Z'): + return + self.assertEqual(result.returncode, 0, result.stderr) if time.monotonic() >= deadline: self.fail(f'Owned descendant {pid} is still running') time.sleep(0.02) diff --git a/desktop/test/browser-storage-smoke.cjs b/desktop/test/browser-storage-smoke.cjs index d3615c7..a92fc1f 100644 --- a/desktop/test/browser-storage-smoke.cjs +++ b/desktop/test/browser-storage-smoke.cjs @@ -165,14 +165,15 @@ async function parent() { return JSON.parse(line); } try { + const primaryMode = process.platform === 'darwin' ? 'macos' : 'wsl'; let handoff = await start(); const port = Number(new URL(handoff.origin).port); - const expected = await phase(handoff, 'wsl', 'write'); + const expected = await phase(handoff, primaryMode, 'write'); const oldToken = handoff.session_token; await stop(); handoff = await start(port); assert.notEqual(handoff.session_token, oldToken); - await phase(handoff, 'wsl', 'read', expected); + await phase(handoff, primaryMode, 'read', expected); await phase(handoff, 'windows', 'empty', expected); await stop(); for (let attempt = 0; attempt < 5; attempt++) { @@ -181,7 +182,7 @@ async function parent() { await stop(); } assert.notEqual(Number(new URL(handoff.origin).port), port, 'Could not allocate a different test origin'); - await phase(handoff, 'wsl', 'empty', expected); + await phase(handoff, primaryMode, 'empty', expected); console.log('Storage smoke passed: full process/backend restart, preferences, profiles, both CryptoKeys, stale-cookie reset and mode/origin isolation.'); } finally { await stop(); } } diff --git a/desktop/test/capture-smoke.cjs b/desktop/test/capture-smoke.cjs index 345dfe8..f4b6b43 100644 --- a/desktop/test/capture-smoke.cjs +++ b/desktop/test/capture-smoke.cjs @@ -55,6 +55,7 @@ async function run(win, capture) { await new Promise(resolve => setTimeout(resolve, 300)); await capture.screenshot('file', path.join(directory, 'source.png')); const started = await capture.start(video); + console.log('Capture smoke: recording started.'); assert.equal(started.destination, video); assert.equal(capture.state, 'recording'); assert.equal(Menu.getApplicationMenu().getMenuItemById('capture-start').enabled, false); @@ -72,18 +73,21 @@ async function run(win, capture) { stream.getTracks().forEach(track => track.stop()); return false; }, () => true)`, true); assert.equal(denied, true, 'a second media request must not reuse the native recording grant'); + console.log('Capture smoke: repeated display capture denied.'); for (const contents of [win.webContents, capture.job.recorder.webContents]) { const cameraDenied = await contents.executeJavaScript(` navigator.mediaDevices.getUserMedia({video: true, audio: true}).then(stream => { stream.getTracks().forEach(track => track.stop()); return false; }, () => true)`, true); assert.equal(cameraDenied, true, 'camera/microphone access must remain denied'); + console.log('Capture smoke: camera/microphone request denied.'); } const pageDenied = await win.webContents.executeJavaScript(` navigator.mediaDevices.getDisplayMedia({video: true}).then(stream => { stream.getTracks().forEach(track => track.stop()); return false; }, () => true)`, true); assert.equal(pageDenied, true, 'the terminal page must not start a capture'); + console.log('Capture smoke: terminal display capture denied.'); const originalMessage = dialog.showMessageBox; dialog.showMessageBox = async () => ({ response: 0 }); try { diff --git a/desktop/test/core-files.test.cjs b/desktop/test/core-files.test.cjs new file mode 100644 index 0000000..85401e6 --- /dev/null +++ b/desktop/test/core-files.test.cjs @@ -0,0 +1,50 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { coreFiles, validateCoreFiles, REQUIRED } = require('../core-files.cjs'); + +const root = path.resolve(__dirname, '../..'); +const tracked = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8' }).split('\0').filter(Boolean); + +test('Core bundle includes public launchers, skills, references and helpers', () => { + const files = coreFiles(tracked); + validateCoreFiles(root, files); + for (const file of REQUIRED.filter(file => file.startsWith('docs/'))) assert.ok(files.includes(file), file); + for (const file of ['.git/config', 'desktop/dist/private.json', 'tools/.venv_wsl/bin/python', + 'handover_20260909.md', 'AGENTS.md', 'tests/agent_backend_smoke.py']) assert.ok(!coreFiles([file]).length, file); + assert.ok(!files.includes('scripts/base64d.sh')); + assert.ok(!files.includes('scripts/base64d_probe.sh')); + for (const required of REQUIRED) { + assert.throws(() => validateCoreFiles(root, files.filter(file => file !== required)), /Missing required Core input/); + } +}); + +test('Core selection excludes private documents even when tracked internally', () => { + const privateFiles = ['docs/internal/handover_20260909.md', 'docs/internal/access.md', + 'docs/examples/private-agent/SKILL.md', 'README_INTERNAL.md', 'AGENTS.md']; + for (const file of privateFiles) assert.ok(!coreFiles([...tracked, ...privateFiles]).includes(file), file); +}); + +test('Core validation rejects missing, non-file and incomplete skill payloads', t => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'standterm-core-files-test-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + for (const file of REQUIRED) { + const target = path.join(directory, file); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, 'fixture'); + } + validateCoreFiles(directory, REQUIRED); + const skill = path.join(directory, 'docs/examples/standterm-external-agent-skill/SKILL.md'); + fs.writeFileSync(skill, '[Missing](references/missing.md)'); + assert.throws(() => validateCoreFiles(directory, REQUIRED), /Missing bundled skill reference/); + fs.writeFileSync(skill, 'fixture'); + fs.unlinkSync(skill); + assert.throws(() => validateCoreFiles(directory, REQUIRED), /ENOENT/); + fs.mkdirSync(skill); + assert.throws(() => validateCoreFiles(directory, REQUIRED), /Not a regular Core input/); +}); diff --git a/desktop/test/macos-setup-integration.cjs b/desktop/test/macos-setup-integration.cjs new file mode 100644 index 0000000..d7d71cd --- /dev/null +++ b/desktop/test/macos-setup-integration.cjs @@ -0,0 +1,65 @@ +'use strict'; + +// Run with checkout Electron, a prepared project venv and a staged bundle. +// Real setup/progress UI and Python children; only user choices and runtime paths +// are redirected, so no installed app, account runtime or live profile is used. +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const syncFs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { spawn } = require('node:child_process'); +const electron = require('electron'); +const { app } = electron; +const directory = syncFs.mkdtempSync(path.join(__dirname, '..', 'dist', 'macos-setup-test-')); +const profile = path.join(directory, 'profile'); +syncFs.mkdirSync(profile); +app.enableSandbox(); +app.setPath('userData', profile); +app.on('window-all-closed', () => {}); + +async function main() { + assert.equal(process.platform, 'darwin'); + const [python, stage] = process.argv.slice(2); + if (!python || !stage) throw new Error('Pass the prepared venv Python and macOS stage directory.'); + const runtime = path.join(directory, 'runtime'); + // Pin the project venv; tests never execute project code with system Python. + await fs.writeFile(path.join(profile, 'launcher.json'), JSON.stringify({ version: 1, python })); + await app.whenReady(); + let preparations = 0; + let confirmations = 0; + const setupElectron = { ...electron, dialog: { ...electron.dialog, + showMessageBox: async options => { + assert.equal(options.title, 'Prepare StandTerm Core'); + assert.match(options.message, /macOS/); + assert.equal(options.defaultId, 0); + confirmations++; + return { response: 1 }; + }, + } }; + const setupSpawn = (executable, args, options) => { + assert.equal(executable, python); + const bootstrap = args.some(value => value === path.join(stage, 'bundle', 'bootstrap.py')); + if (args.includes('--prepare')) preparations++; + return spawn(executable, bootstrap ? [...args, '--test-root', runtime] : args, options); + }; + const context = vm.createContext({ module: { exports: {} }, __dirname: path.resolve(__dirname, '..'), + require: name => name === 'electron' ? setupElectron : name === 'node:child_process' ? { spawn: setupSpawn } + : name.startsWith('./') ? require(path.join(__dirname, '..', name)) : require(name), + process: { resourcesPath: stage, arch: process.arch, env: process.env }, Buffer, setTimeout, clearTimeout }); + vm.runInContext(await fs.readFile(path.join(__dirname, '..', 'setup.cjs'), 'utf8'), context); + const command = await context.module.exports.preparePackagedBackend('macos'); + assert.equal(command.executable, path.join(runtime, 'tools', '.venv_macos', 'bin', 'python')); + assert.equal(command.cwd, runtime); + assert.equal(preparations, 1); + assert.equal(confirmations, 1); + const reused = await context.module.exports.preparePackagedBackend('macos'); + assert.equal(reused.executable, command.executable); + assert.equal(preparations, 1, 'ready reuse must not run pip again'); + assert.equal(confirmations, 1); + assert.equal(electron.BrowserWindow.getAllWindows().length, 0); + console.log(`macOS setup integration passed: real progress window, dependency install and reuse; ${runtime}`); + app.exit(0); +} + +main().catch(error => { console.error(error.stack); app.exit(1); }); diff --git a/desktop/test/macos.test.cjs b/desktop/test/macos.test.cjs new file mode 100644 index 0000000..21a4e49 --- /dev/null +++ b/desktop/test/macos.test.cjs @@ -0,0 +1,47 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { desktopMode } = require('../desktop-mode.cjs'); +const { macPythonCandidates, validMacPython } = require('../macos-python.cjs'); +const { backendCommand } = require('../policy.cjs'); +const { browserSessionOptions } = require('../browser-session.cjs'); +const { agentConnectionInfo } = require('../diagnostics.cjs'); + +test('macOS defaults to native mode and rejects incompatible or duplicate backend flags', () => { + assert.equal(desktopMode([], 'darwin'), 'macos'); + assert.equal(desktopMode([], 'win32'), 'windows'); + assert.equal(desktopMode(['--backend=wsl'], 'win32'), 'wsl'); + assert.equal(desktopMode([], 'linux'), 'windows'); // Existing source/WSLg diagnostic mode. + for (const argv of [['--backend=windows'], ['--backend=wsl'], ['--backend=unknown'], + ['--backend=macos', '--backend=macos']]) assert.throws(() => desktopMode(argv, 'darwin')); + assert.throws(() => desktopMode(['--backend=macos'], 'win32')); + const command = backendCommand('/Projects/Stand Term', 'darwin', {}); + assert.equal(command.executable, path.join('/Projects/Stand Term', 'tools', '.venv_macos', 'bin', 'python')); + assert.deepEqual(command.args, ['-u', path.join('/Projects/Stand Term', 'desktop', 'backend.py')]); + assert.equal(browserSessionOptions('macos').partition, 'persist:standterm-ui-macos-v1'); + assert.equal(agentConnectionInfo({ origin: 'http://127.0.0.1:12345', mode: 'macos', + instanceId: 'test' }).backend_mode, 'macos'); +}); + +test('Finder Python discovery includes MacPorts, preserves saved paths, and skips Apple stubs', () => { + const candidates = macPythonCandidates('/Custom Python/bin/python3', { PATH: '/usr/bin:relative:/opt/local/bin' }); + assert.equal(candidates[0], '/Custom Python/bin/python3'); + assert.ok(candidates.includes('/opt/local/bin/python3')); + assert.ok(candidates.includes('/opt/homebrew/bin/python3')); + assert.ok(!candidates.includes('/usr/bin/python3')); + assert.equal(new Set(candidates).size, candidates.length); + assert.ok(!macPythonCandidates('relative').includes('relative')); +}); + +test('Python acceptance requires native architecture and a usable macOS venv interpreter', () => { + const info = { type: 'python_info', platform: 'darwin', bits: 64, venv: true, + version: [3, 13], machine: 'arm64', executable: '/opt/local/bin/python3' }; + assert.equal(validMacPython(info, 'arm64'), true); + assert.equal(validMacPython({ ...info, machine: 'x86_64' }, 'x64'), true); + for (const change of [{ machine: 'x86_64' }, { platform: 'linux' }, { version: [3, 9] }, + { bits: 32 }, { venv: false }, { executable: '/usr/bin/python3' }, { executable: 'python3' }]) { + assert.equal(validMacPython({ ...info, ...change }, 'arm64'), false); + } +}); diff --git a/desktop/test/macos_shell_smoke.py b/desktop/test/macos_shell_smoke.py new file mode 100644 index 0000000..d842e33 --- /dev/null +++ b/desktop/test/macos_shell_smoke.py @@ -0,0 +1,72 @@ +"""Verify Finder-style PATH handling with synthetic zsh profiles and commands.""" + +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +# Import Core with isolated state before constructing any shell configuration. +STATE = tempfile.TemporaryDirectory(prefix='standterm-macos-shell-') +os.environ.update({ + 'STANDTERM_AGENT_RUNTIME_DIR': str(Path(STATE.name) / 'agent'), + 'STANDTERM_SESSION_RECOVERY_STORE': str(Path(STATE.name) / 'recovery.json'), + 'STANDTERM_DISABLE_AGENTINFO_CURRENT': '1', +}) +import app as standterm + + +class MacShellTests(unittest.TestCase): + @unittest.skipUnless(sys.platform == 'darwin', 'Native macOS zsh integration') + def test_desktop_loads_login_profile_with_minimal_finder_path(self): + with tempfile.TemporaryDirectory(prefix='standterm-zprofile-') as directory: + root = Path(directory).resolve() + binaries = root / 'bin' + binaries.mkdir() + tmux = binaries / 'tmux' + tmux.write_text('#!/bin/sh\nprintf "standterm-synthetic-tmux\\n"\n') + tmux.chmod(0o700) + # Quoted to cover package/user paths with spaces without using the + # operator's actual startup files or spawning a real tmux server. + (root / '.zprofile').write_text(f'export PATH="{binaries}:$PATH"\n') + (root / '.zshrc').write_text('export STANDTERM_TEST_INTERACTIVE=loaded\n') + env = {**os.environ, 'PATH': '/usr/bin:/bin:/usr/sbin:/sbin', + 'SHELL': '/bin/zsh', 'ZDOTDIR': str(root), 'STANDTERM_TEST_TMUX': str(tmux)} + for desktop in [False, True]: + with patch.dict(os.environ, env), patch.dict(standterm.app.config, {'DESKTOP_LOGIN_SHELL': desktop}): + config, error = standterm.get_default_local_shell_config() + self.assertIsNone(error) + self.assertEqual(config['shell_command'], ['/bin/zsh', '-l'] if desktop else ['/bin/zsh']) + result = subprocess.run([*config['shell_command'], '-i', '-c', + 'test "$STANDTERM_TEST_INTERACTIVE" = loaded && ' + 'test "$(command -v tmux)" = "$STANDTERM_TEST_TMUX" && command -v tmux && tmux'], + env=env, capture_output=True, text=True, timeout=10) + if desktop: + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.splitlines(), [str(tmux), 'standterm-synthetic-tmux']) + else: + self.assertNotEqual(result.returncode, 0) + self.assertNotIn(str(tmux), result.stdout) + self.assertNotIn('standterm-synthetic-tmux', result.stdout) + + def test_login_flag_only_changes_known_macos_desktop_shells(self): + for platform, shell, desktop, expected in [ + ('darwin', '/bin/zsh', True, ['/bin/zsh', '-l']), + ('darwin', '/bin/zsh', False, ['/bin/zsh']), + ('darwin', '/custom/shell-wrapper', True, ['/custom/shell-wrapper']), + ('linux', '/bin/zsh', True, ['/bin/zsh']), + ]: + with patch.object(sys, 'platform', platform), patch.object(standterm, 'is_wsl', return_value=False), \ + patch.dict(os.environ, {'SHELL': shell}), \ + patch.dict(standterm.app.config, {'DESKTOP_LOGIN_SHELL': desktop}): + config, error = standterm.get_default_local_shell_config() + self.assertIsNone(error) + self.assertEqual(config['shell_command'], expected) + + +if __name__ == '__main__': + unittest.main() diff --git a/desktop/test/package-inspect.cjs b/desktop/test/package-inspect.cjs index 5270662..41bd46a 100644 --- a/desktop/test/package-inspect.cjs +++ b/desktop/test/package-inspect.cjs @@ -6,6 +6,7 @@ const fs = require('node:fs'); const path = require('node:path'); const assert = require('node:assert/strict'); const { createHash } = require('node:crypto'); +const { validateCoreFiles } = require('../core-files.cjs'); const [resourcesArg, stageArg, asarModule] = process.argv.slice(2); if (!resourcesArg || !stageArg || !asarModule) throw new Error('Pass resources, stage and @electron/asar module paths.'); const resources = path.resolve(resourcesArg); @@ -15,6 +16,7 @@ const archive = path.join(resources, 'app.asar'); const manifest = JSON.parse(fs.readFileSync(path.join(resources, 'bundle', 'manifest.json'), 'utf8')); const stagedManifest = JSON.parse(fs.readFileSync(path.join(stage, 'bundle', 'manifest.json'), 'utf8')); assert.deepEqual(manifest, stagedManifest); +validateCoreFiles(path.join(resources, 'bundle', 'core'), Object.keys(manifest.files)); const hash = bytes => createHash('sha256').update(bytes).digest('hex'); for (const [file, expected] of Object.entries(manifest.files)) { assert.equal(hash(fs.readFileSync(path.join(resources, 'bundle', 'core', file))), expected, file); @@ -28,7 +30,7 @@ for (const name of names) { assert.deepEqual(asar.extractFile(archive, relative), fs.readFileSync(input), relative); } } -for (const file of ['main.cjs', 'browser-session.cjs', 'diagnostics.cjs', 'diagnostics-window.cjs', +for (const file of ['main.cjs', 'agent-menu.cjs', 'browser-session.cjs', 'diagnostics.cjs', 'diagnostics-window.cjs', 'external-links.cjs', 'floating-windows.cjs', 'test/external-links-smoke.cjs']) { assert.ok(names.includes('/' + file), `Missing ${file}`); } diff --git a/desktop/test/runtime_smoke.py b/desktop/test/runtime_smoke.py index 7e01094..3c6bc86 100644 --- a/desktop/test/runtime_smoke.py +++ b/desktop/test/runtime_smoke.py @@ -17,8 +17,13 @@ class RuntimeTests(unittest.TestCase): + def test_macos_uses_application_support_and_its_own_venv(self): + with patch.object(sys, 'platform', 'darwin'), patch.object(Path, 'home', return_value=Path('/Users/test')): + self.assertEqual(runtime.runtime_base(), Path('/Users/test/Library/Application Support/StandTermDesktop')) + self.assertEqual(runtime.venv_path(Path('/runtime')), Path('/runtime/tools/.venv_macos')) + def fixture(self, marker=True): - base = Path(tempfile.mkdtemp(prefix='standterm-runtime-test-')) + base = Path(tempfile.mkdtemp(prefix='standterm-runtime-test-')).resolve() root = base / 'runtimes' / ('a' * 64) venv = runtime.venv_path(root) venv.mkdir(parents=True) diff --git a/desktop/test/setup.test.cjs b/desktop/test/setup.test.cjs index a69df05..dddc0a0 100644 --- a/desktop/test/setup.test.cjs +++ b/desktop/test/setup.test.cjs @@ -9,6 +9,7 @@ const vm = require('node:vm'); const { EventEmitter } = require('node:events'); async function fixture({ consent = true, pythonMissing = false, ready = false, native = false, + macos = false, machine = 'arm64', wrongVenv = false, holdPrepare = false, cleanupConfirm = false, closeDecision = async () => ({ response: 0 }) } = {}) { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'standterm-setup-test-')); await fs.mkdir(path.join(root, 'bundle')); @@ -55,8 +56,8 @@ async function fixture({ consent = true, pythonMissing = false, ready = false, n } dialogs++; assert.equal(options.cancelId, options.defaultId); - if (!native && dialogs === 1) return { response: 0 }; - if (options.message.includes('Install 64-bit')) return { response: 0 }; + if (!native && !macos && dialogs === 1) return { response: 0 }; + if (options.title === 'StandTerm Desktop: Python required') return { response: 0 }; assert.match(options.detail, /Requires Python 3.10\+/); return { response: consent ? 1 : 0 }; } }, @@ -64,7 +65,8 @@ async function fixture({ consent = true, pythonMissing = false, ready = false, n webRequest: { onBeforeRequest() {} } }) }, }; const spawn = (executable, args, options) => { - assert.ok(native ? ['where.exe', 'C:\\Python\\python.exe'].includes(executable) : executable === 'wsl.exe'); + assert.ok(macos ? executable.startsWith('/') && !executable.startsWith('/usr/bin/') + : native ? ['where.exe', 'C:\\Python\\python.exe'].includes(executable) : executable === 'wsl.exe'); assert.equal(options.shell, false); calls.push(args); const child = new EventEmitter(); @@ -82,10 +84,12 @@ async function fixture({ consent = true, pythonMissing = false, ready = false, n else if (args.includes('wslpath')) bytes = Buffer.from('/mnt/c/Program Files/bundle\n'); else if (pythonMissing) { bytes = Buffer.alloc(0); code = 127; } else if (args.includes('-c')) bytes = Buffer.from(JSON.stringify({ type: 'python_info', - executable: 'C:\\Python\\python.exe', platform: 'win32', bits: 64, venv: true, version: [3, 12] }) + '\n'); + executable: macos ? '/opt/local/bin/python3' : 'C:\\Python\\python.exe', platform: macos ? 'darwin' : 'win32', + machine, bits: 64, venv: true, version: [3, 12] }) + '\n'); else if (args.includes('--prepare') || ready) bytes = Buffer.from(JSON.stringify({ type: 'ready', bundle_id: id, root: native ? 'C:\\Runtime' : '/home/test/runtime', - python: native ? 'C:\\Runtime\\tools\\.venv_win\\Scripts\\python.exe' : '/home/test/runtime/tools/.venv_wsl/bin/python', + python: native ? 'C:\\Runtime\\tools\\.venv_win\\Scripts\\python.exe' + : `/home/test/runtime/tools/${macos && !wrongVenv ? '.venv_macos' : '.venv_wsl'}/bin/python`, }) + '\n'); else bytes = Buffer.from(JSON.stringify({ type: 'needs_setup', bundle_id: id }) + '\n'); const complete = () => { child.stdout.emit('data', bytes); child.emit('close', code); }; @@ -98,10 +102,11 @@ async function fixture({ consent = true, pythonMissing = false, ready = false, n return child; }; const context = vm.createContext({ module: { exports: {} }, __dirname: path.join(__dirname, '..'), - require: name => name === 'electron' ? electron : name === 'node:child_process' ? { spawn } : require(name), - process: { resourcesPath: root }, Buffer, setTimeout, clearTimeout }); + require: name => name === 'electron' ? electron : name === 'node:child_process' ? { spawn } + : name === './macos-python.cjs' ? require('../macos-python.cjs') : require(name), + process: { resourcesPath: root, arch: 'arm64', env: {} }, Buffer, setTimeout, clearTimeout }); vm.runInContext(await fs.readFile(path.join(__dirname, '..', 'setup.cjs'), 'utf8'), context); - return { run: options => context.module.exports.preparePackagedBackend(native ? 'windows' : 'wsl', options), root, calls, + return { run: options => context.module.exports.preparePackagedBackend(macos ? 'macos' : native ? 'windows' : 'wsl', options), root, calls, cleanup: () => context.module.exports.cleanupManagedVenvs(native ? 'windows' : 'wsl'), preparing, window: () => progressWindow, child: () => preparedChild, complete: () => completePrepare(), closeDialogs: () => closeDialogs, quit: () => context.module.exports.confirmSetupQuit() }; @@ -132,6 +137,29 @@ test('ready managed environments are reused without running pip again', async () assert.equal(f.calls.some(args => args.includes('--prepare')), false); }); +test('macOS prepares a native runtime and reuses it with only Python launcher metadata', async () => { + for (const ready of [false, true]) { + const f = await fixture({ macos: true, ready }); + const command = await f.run(); + assert.equal(command.executable, '/home/test/runtime/tools/.venv_macos/bin/python'); + assert.deepEqual(Array.from(command.args), ['-u', '/home/test/runtime/desktop/backend.py']); + assert.equal(f.calls.filter(args => args.includes('--prepare')).length, Number(!ready)); + assert.equal(f.calls.some(args => args.includes('--distribution')), false); + assert.deepEqual(JSON.parse(await fs.readFile(path.join(f.root, 'launcher.json'), 'utf8')), + { version: 1, python: '/opt/local/bin/python3' }); + } +}); + +test('macOS refuses missing or mismatched Python, cancellation and WSL runtime responses', async () => { + for (const options of [{ pythonMissing: true }, { machine: 'x86_64' }, { consent: false }, + { ready: true, wrongVenv: true }]) { + const f = await fixture({ macos: true, ...options }); + await assert.rejects(f.run(), /canceled|Invalid managed runtime/); + assert.equal(f.calls.some(args => args.includes('--prepare')), false); + await assert.rejects(fs.stat(path.join(f.root, 'launcher.json')), { code: 'ENOENT' }); + } +}); + test('native Windows uses its own interpreter and never starts WSL', async () => { const f = await fixture({ native: true }); const command = await f.run(); diff --git a/desktop/test/squirrel-events.test.cjs b/desktop/test/squirrel-events.test.cjs index 21dea60..fef80bf 100644 --- a/desktop/test/squirrel-events.test.cjs +++ b/desktop/test/squirrel-events.test.cjs @@ -9,8 +9,8 @@ const { isSquirrelEvent, handleSquirrelEvent, shortcutSpecs } = require('../squi const { desktopMode } = require('../desktop-mode.cjs'); test('backend mode selection is explicit and rejects ambiguous or unknown modes', () => { - assert.equal(desktopMode(['app']), 'windows'); - assert.equal(desktopMode(['app', '--backend=wsl']), 'wsl'); + assert.equal(desktopMode(['app'], 'win32'), 'windows'); + assert.equal(desktopMode(['app', '--backend=wsl'], 'win32'), 'wsl'); assert.throws(() => desktopMode(['--backend=windows', '--backend=wsl'])); assert.throws(() => desktopMode(['--backend=anything'])); assert.equal(isSquirrelEvent(['app', '--squirrel-install']), true); diff --git a/docs/agent_socket_contract.md b/docs/agent_socket_contract.md index 50c29f9..8c97cff 100644 --- a/docs/agent_socket_contract.md +++ b/docs/agent_socket_contract.md @@ -137,6 +137,22 @@ runtime root. Graceful shutdown removes the current instance directory; a token left by a crash is invalid after server restart. Agents should call `hello` first when possible and branch only on the typed `capabilities` field, not on displayed terminal text. +The tokenless `/agentinfo` document reports `instance_id`, `launch_dir`, +`python_path` and absolute `scripts` paths for the active backend. `skills` maps +`standterm-external-agent`, `standterm-file-transfer` and +`standterm-privileged-hitl` to `path` (SKILL.md), `boot_prompt_path`, +`install_prompt_path` and `available` (all three entry files exist). The existing +singular `skill` remains an alias for the external-agent entry. Support helpers +include `agent_rsfile` and `agent_mcp`; advertising their paths does not enable +rescue transfers or install optional MCP dependencies. + +Clients given a connection prompt must verify the reported instance ID before +using its paths. Paths belong to the backend OS/filesystem, not necessarily the +agent's environment. If documents are absent, identity differs or paths cannot +be accessed, report the limitation rather than guessing endpoints or overwriting +an existing skill. Desktop bundles include these public documents and helpers; +skill installation remains separate from terminal authorization. + See `docs/examples/standterm-external-agent-skill/SKILL.md` and the adjacent `skill_prompt.txt` for a local skill example that wraps this workflow for CLI agents. diff --git a/tests/agent_backend_smoke.py b/tests/agent_backend_smoke.py index 6eff733..4f65f3d 100644 --- a/tests/agent_backend_smoke.py +++ b/tests/agent_backend_smoke.py @@ -3210,6 +3210,28 @@ def test_external_agentinfo_payload_route_and_pointer_are_tokenless(): assert '--handoff' in payload['recommended_commands']['render_after_token_mint'] assert '--handoff' in payload['recommended_commands']['shcmd_after_token_mint'] assert 'scripts/agent_shcmd.py' in payload['scripts']['agent_shcmd'] + assert payload['instance_id'] == standterm.LAUNCHER_INSTANCE_ID + assert set(payload['skills']) == { + 'standterm-external-agent', 'standterm-file-transfer', 'standterm-privileged-hitl', + } + assert payload['skill'] == payload['skills']['standterm-external-agent'] + for skill in payload['skills'].values(): + assert skill['available'] is True + for key in ('path', 'boot_prompt_path', 'install_prompt_path'): + assert Path(skill[key]).is_file() + assert Path(skill[key]).is_relative_to(Path(payload['launch_dir'])) + for helper in payload['scripts'].values(): + assert Path(helper).is_file() + assert Path(helper).is_relative_to(Path(payload['launch_dir'])) + + original_app_dir = standterm.APP_DIR + with tempfile.TemporaryDirectory(prefix='standterm-missing-skills-') as missing_dir: + try: + standterm.APP_DIR = Path(missing_dir) + missing = standterm.build_external_agentinfo_payload() + assert all(skill['available'] is False for skill in missing['skills'].values()) + finally: + standterm.APP_DIR = original_app_dir blocked = flask_client.get('/agentinfo', environ_overrides={'REMOTE_ADDR': '203.0.113.10'}) assert blocked.status_code == 403 @@ -7434,7 +7456,9 @@ def comports(): original_is_wsl = standterm.is_wsl original_detect_windows = standterm.detect_windows_serial_ports_for_wsl original_get_serial_modules = standterm.get_serial_modules + original_platform = sys.platform try: + sys.platform = 'linux' standterm.is_wsl = lambda: True standterm.detect_windows_serial_ports_for_wsl = lambda: [{ 'device': 'COM3', @@ -7450,6 +7474,7 @@ def comports(): standterm.is_wsl = original_is_wsl standterm.detect_windows_serial_ports_for_wsl = original_detect_windows standterm.get_serial_modules = original_get_serial_modules + sys.platform = original_platform by_device = {port['device']: port for port in ports} assert list(by_device) == ['COM3', '/dev/ttyACM0', '/dev/ttyUSB0']