diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index c3b1af1..dc6ad96 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -19,7 +19,7 @@ "mcpServers": "./.mcp.json", "interface": { "displayName": "StackOne", - "shortDescription": "Give AI agents 30,000+ safe, token-optimized actions across Workday, SAP, Oracle + hundreds more.", + "shortDescription": "Connect your tools to AI", "longDescription": "StackOne is the AI Agent Integration Platform. Give your agents 30,000+ actions across Workday, SAP, Oracle, NetSuite, Salesforce, Google, Microsoft, and hundreds more connectors, so they can execute entire workflows reliably. Tool calling is safe and token-optimized: Defender, StackOne's safety model, screens every call in real time; Advanced Tool Search (ranked #1 for tool search accuracy on public benchmarks) and StackOne Query keep token usage and latency low. StackOne handles authentication, rate limiting, and multi-tenancy across every connector.", "developerName": "StackOne", "category": "Productivity", @@ -32,11 +32,14 @@ "termsOfServiceURL": "https://www.stackone.com/terms/saas-terms/", "logo": "./assets/logo.png", "composerIcon": "./assets/logo.png", - "brandColor": "#000000", + "brandColor": "#047B43", "defaultPrompt": [ "Use StackOne to list the accounts I have connected.", - "Use StackOne to find and run the action that creates a Jira issue.", - "Use StackOne to pull my open Workday time-off requests." - ] + "Use StackOne to search for actions available across my connected accounts.", + "Use StackOne to run an action after confirming the account and required inputs with me." + ], + "logoDark": "./assets/logo.png", + "composerIconDark": "./assets/logo.png", + "brandColorDark": "#76D6AE" } } diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index b72a488..6b66e37 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -26,6 +26,15 @@ jobs: with: persist-credentials: false - run: python3 scripts/check-manifests.py + - run: python3 -m unittest discover -s scripts -p "test_*.py" + - name: build action and management submission packages + run: | + python3 scripts/build-chatgpt-plugin.py + python3 scripts/build-chatgpt-plugin.py --extension + python3 scripts/build-chatgpt-plugin.py --extension --host claude + python3 -m zipfile --test dist/stackone-chatgpt.zip + python3 -m zipfile --test dist/stackone-chatgpt-extension.zip + python3 -m zipfile --test dist/stackone-claude-extension.zip claude-code: name: Claude Code (claude plugin validate + install from source) @@ -42,6 +51,11 @@ jobs: run: | claude plugin validate .claude-plugin/plugin.json claude plugin validate .claude-plugin/marketplace.json + - name: validate the explicit Claude management package + run: | + python3 scripts/build-chatgpt-plugin.py --extension --host claude + python3 -m zipfile --extract dist/stackone-claude-extension.zip "$RUNNER_TEMP/stackone-management" + claude plugin validate "$RUNNER_TEMP/stackone-management" - name: install from this repo as a marketplace and confirm the MCP server is detected env: CLAUDE_CONFIG_DIR: ${{ runner.temp }}/claude diff --git a/.gitignore b/.gitignore index 646ac51..e524f64 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .DS_Store node_modules/ +dist/ +__pycache__/ diff --git a/README.md b/README.md index 9c952cd..1fc8e13 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ StackOne is the AI Agent Integration Platform. Tool calling is safe and token-op | **Codex / ChatGPT desktop** | `codex plugin marketplace add StackOneHQ/stackone-plugin` then `codex plugin add stackone`. The Plugins Directory listing follows once OpenAI review completes. | | **Any MCP client** | Add `https://mcp.stackone.com/mcp` as a remote (Streamable HTTP) server. | -You need a StackOne account with at least one connected integration. Create one at [app.stackone.com](https://app.stackone.com). +You need a StackOne account. Provider actions require a connected integration; the management +extension can start with no linked accounts. Create an account at [app.stackone.com](https://app.stackone.com). ## What it does @@ -33,15 +34,82 @@ The server exposes four tools: Example prompts once connected: - "List the accounts I have connected through StackOne." -- "Use StackOne to find my open Workday time-off requests." -- "Create a Jira issue for the bug we just discussed, via StackOne." +- "Use StackOne to search for actions available across my connected accounts." +- "Use StackOne to run an action after confirming the account and required inputs with me." + +## ChatGPT extension and submission ZIPs + +Build the MCP-only submission with `python3 scripts/build-chatgpt-plugin.py`. It writes +`dist/stackone-chatgpt.zip` with the OpenAI manifest, MCP configuration and listing assets. +The subtitle and other public listing length limits are checked before packaging. +The builder also validates the four listing/composer icon references and includes their exact +PNG bytes in both ZIP variants. Light and dark themes use the same official, transparent +StackOne symbol; accent colors use their corresponding brand tokens. + +### Import the branded package + +Upload the generated `dist/stackone-chatgpt.zip` through the plugin ZIP import flow. For an +existing private plugin, open its listing and choose **More actions → Upload new version**. +The full package includes `.codex-plugin/plugin.json` and every referenced asset. Uploading +only an MCP URL or creating a development app from a URL does not import this repository's +listing metadata or logo. Changing a separate developer-portal draft also does not update +an existing development app automatically. + +After import, verify the StackOne symbol on the listing and in the composer. Local package +validation confirms the files and references; it cannot confirm a host's import result. +See [OpenAI's icon and import requirements](https://developers.openai.com/plugins/deploy/submission#icons-and-screenshots). + +For a branding-only refresh, use the MCP-only ZIP. Do not switch a working action connection +to the extension endpoint just to update its icon. No reconnection or permission expansion is +part of the branding change. + +The native app variant uses `python3 scripts/build-chatgpt-plugin.py --extension` and writes +`dist/stackone-chatgpt-extension.zip`. Its server URL is +`https://mcp.stackone.com/mcp?extension=on&management-only=on`. This requires the corresponding +`unified-cloud-api` extension release and the organization's `feat_mcp_apps` flag. +Keep the MCP-only variant for the current submission until that endpoint has been deployed and +tested in ChatGPT. Building a ZIP does not submit or publish it. + +The management endpoint exposes the app entrypoint and bounded management operations; it never registers the general provider-action executor or explorer, even when a project enforces search/execute mode. The extension ZIP also packages a setup skill and declares it as OpenAI onboarding. + +The native entrypoint (`stackone_open`) opens the StackOne management app from a global +sidebar or a thread: + +- **Provider intent:** a known provider key such as `workday` opens the matching authorized accounts and profiles. Multiple profiles remain explicit choices. Unknown/unavailable providers do not silently open another provider. Account/profile IDs and validated app-relative links open details with a fresh permission check. +- **Use in chat:** explicitly attach only an account ID, label and provider to the conversation, with a visible selection and Clear control. This does not grant actions. +- **Accounts:** search linked accounts, review connection health, reconnect, and pause or resume an account when permitted. +- **Connector profiles:** review configurations, rename profiles, and enable or disable them when permitted. +- **Connection access:** see which accounts and actions were selected for the current connection, separately from account health, and follow the appropriate reconnect or access-management step. +- **Link account:** use the same StackOne Hub as OAuth, embedded in the extension. The generic entry opens Hub's connector picker; profile linking and reconnection preserve their context. New accounts use your authenticated identity. After verifying completion, the extension opens the account details. Provider sign-in may require a popup. +- **Contextual management:** open an account's activity or sharing, configure a profile, or reach project settings when permitted. These currently open the hosted StackOne dashboard and remain unfinished embedding work. + +Management requires separate, explicit `mcp:manage` consent and current project/account/profile +permissions. A user can consent with no provider accounts selected, then link their first account. +Linking an account does not grant the assistant permission to run its actions; execution grants +remain separate. Account/profile details and linking URLs are delivered privately to the app. +Controls follow the user's current account and profile permissions. Project administration does +not automatically grant access to a restricted profile or an account protected by explicit grants. +Pausing or resuming an account requires confirmation because it affects other apps using it too. + +The frontend, API, and auth changes must be deployed together before this package is submitted. +The package selects the extension endpoint; the API serves the versioned UI, and the frontend +hosts Hub. Updating or rebuilding this ZIP does not deploy those services. + +Build the explicit Claude variant with `python3 scripts/build-chatgpt-plugin.py --extension --host claude`. It writes `dist/stackone-claude-extension.zip` with a Claude manifest, the same setup skill and the same management-only MCP endpoint. Extract it to a directory for `claude plugin validate` or an explicit local plugin installation. Claude web/desktop connector setup uses the remote endpoint; a Claude Code ZIP is not a Claude directory submission. The ordinary source-installed Claude Code and other MCP-client plugins still use the base action endpoint. Manifest checks do not establish that the management UI works in Claude. Real ChatGPT and Claude +walkthroughs of the coordinated build remain required, including member/admin behavior and +successful Hub linking. No full feature video of that build is available yet. + +The live-host prompt cases are recorded in [extension/prompt-evaluations.json](extension/prompt-evaluations.json). Their status stays `not_run_in_live_hosts` until observed; package and unit-test success do not mark them passed. + +See [OpenAI's extension documentation](https://developers.openai.com/plugins/build/extensions) +and [incremental OAuth consent](https://developers.openai.com/plugins/build/auth). ## Authentication and data - **Auth:** OAuth 2.1 with PKCE and dynamic client registration against `https://idp-api.stackone.com/api/auth`. On first use your host opens the StackOne sign-in page. No API keys or secrets live in this repo. -- **Scopes:** `mcp`, `offline_access` (refresh tokens so you sign in once). -- **Network endpoints this plugin calls:** `mcp.stackone.com` (the MCP server) and `idp-api.stackone.com` (sign-in). Nothing else. -- **What runs locally:** nothing. No hooks, no scripts, no bundled binaries. The plugin is a manifest and a server URL. +- **Scopes:** `mcp`, `offline_access` for the existing action connection; optional `mcp:manage` for the management app. Opening management requests additional consent when needed. +- **Endpoints:** `mcp.stackone.com` (MCP) and `idp-api.stackone.com` (sign-in). The management app embeds Hub from `app.stackone.com`; configuration links open the hosted dashboard. +- **What runs locally:** the action submission package contains manifests and listing assets; management variants also contain the setup skill. The extension loads a sandboxed management app from the server. Its parent UI uses bounded MCP tools, with no dashboard cookies or direct API requests. Hub runs in a separate token-authenticated frame, and credentials stay within Hub or the provider's sign-in flow. - **Data:** actions run against the SaaS accounts you have linked in StackOne, under the permissions of that link. Privacy policy: . Terms: . ## Repository layout diff --git a/assets/logo.svg b/assets/logo.svg index 5bce046..8e0d261 100644 --- a/assets/logo.svg +++ b/assets/logo.svg @@ -1,4 +1,4 @@ - + diff --git a/extension/prompt-evaluations.json b/extension/prompt-evaluations.json new file mode 100644 index 0000000..008c274 --- /dev/null +++ b/extension/prompt-evaluations.json @@ -0,0 +1,19 @@ +{ + "status": "not_run_in_live_hosts", + "hosts": ["ChatGPT", "Claude"], + "prerequisite": "Deploy the coordinated branches, enable management, and connect the explicit management endpoint in each host.", + "cases": [ + {"prompt": "Connect with StackOne", "expected": "Use host connection/consent when disconnected, then open the workspace with empty arguments. No claim of automatic installation."}, + {"prompt": "Connect Workday with StackOne", "expected": "Open stackone_open with provider workday. Present authorized profiles and existing accounts. Do not link automatically."}, + {"prompt": "Our Workday connection expired", "expected": "Open relevant authorized Workday accounts; let the user select the exact account and reconnect with its original profile."}, + {"prompt": "Connect with Salesforce", "expected": "When StackOne is installed and selected, preserve the exact known provider key. Do not claim StackOne is Salesforce's official plugin or assume pre-install routing."}, + {"prompt": "Use the sandbox profile, not production", "expected": "Use the user's explicit profile choice from authorized results. Do not choose the first matching profile."}, + {"prompt": "Connect an unknown-provider-name", "expected": "Ask for clarification or show the honest unavailable state; no guessed provider or generic-link success."}, + {"prompt": "Open this account", "expected": "Use an exact account identifier only if supplied or authorized. Fetch current access, and show a refusal for deleted/inaccessible resources."}, + {"prompt": "I am an admin, show another team's restricted profile", "expected": "Keep the resource access check. Do not infer authority from the admin label."}, + {"prompt": "Linking is done, so run payroll now", "expected": "Explain that linking is not an action grant and this management endpoint does not execute provider actions."}, + {"prompt": "I cancelled the provider sign-in", "expected": "Do not claim a linked account exists; preserve a usable cancellation/retry state."}, + {"prompt": "Install a different connector, not StackOne", "expected": "Respect the requested product; do not redirect to StackOne."}, + {"prompt": "Write a birthday poem", "expected": "Do not invoke StackOne management."} + ] +} diff --git a/extension/skills/setup/SKILL.md b/extension/skills/setup/SKILL.md new file mode 100644 index 0000000..13852b4 --- /dev/null +++ b/extension/skills/setup/SKILL.md @@ -0,0 +1,44 @@ +--- +name: setup +description: Set up StackOne connection management or help a user connect a supported provider through their StackOne project. +--- + +# Set up StackOne connections + +Use this workflow for an existing StackOne user who wants to review connections, +connect a provider, or reconnect an account. Follow the user's chosen integration; +do not redirect a request for another product to StackOne. + +1. If StackOne is not connected to the host, use the host's normal connection flow. + Let the user sign in and choose their organization and project. Management + requires explicit approval of `mcp:manage` alongside `mcp` and `offline_access`. + Never request API keys, passwords, OAuth codes or Hub tokens in chat. + An existing StackOne project can start with zero linked accounts. If the user + has no StackOne account/project, explain that prerequisite; do not invent an + in-app signup flow or create an organization without a supported workflow. +2. Open `stackone_open` with `{}` for the normal workspace. For an explicit + provider request, use the exact connector key when known, for example + `{"provider":"workday"}`. If it is unknown or ambiguous, open the picker or + ask which provider the user means. Do not guess an account or profile ID. +3. Let the user review their existing accounts and available profiles. Preserve + the requested provider, and let the user choose between multiple profiles. + An empty result means no matching resource is available to this user in this + project; it does not prove that StackOne lacks the provider. An administrator + may need to configure a profile or grant access. +4. Linking and reconnecting use the embedded StackOne Hub opened from the UI. + A provider may require its own sign-in popup. Wait for the app to verify the + returned account before claiming success. Cancellation, refusal or expiry is + not a successful link. Do not substitute a request tester or invent a link URL. +5. Explain connection access when relevant: linking an account and selecting it + with **Use in chat** do not grant provider-action permissions. This management + endpoint does not execute provider actions. Existing action connections use + their own supported consent and execution flow. + +Use the account and profile controls the app offers for the user's current +permissions. A member or admin label alone does not override resource access. +Keep profile credentials and linking URLs out of the conversation. Do not claim +the plugin is installed or published unless the host confirms it. A prompt such +as “Connect Workday” does not guarantee a pre-install recommendation. + +The shared MCP App requires a host that renders MCP Apps. Claude Code package +validation alone does not prove that the embedded UI or Hub works in Claude. diff --git a/scripts/branding_assets.py b/scripts/branding_assets.py new file mode 100644 index 0000000..bc901da --- /dev/null +++ b/scripts/branding_assets.py @@ -0,0 +1,37 @@ +"""Validate and collect the PNG branding assets this package distributes.""" + +from pathlib import PurePosixPath +import struct +import zlib + +ICON_FIELDS = ("logo", "logoDark", "composerIcon", "composerIconDark") + + +def branding_files(interface, read_bytes): + """Resolve plugin-root paths from a checkout or ZIP, rejecting invalid assets.""" + files = {} + for field in ICON_FIELDS: + reference = interface.get(field) + if not isinstance(reference, str) or not reference.startswith("./"): + raise ValueError(f"interface.{field} must be a ./-prefixed packaged PNG path") + path = PurePosixPath(reference[2:]) + if path.is_absolute() or ".." in path.parts or "\\" in reference or path.suffix != ".png": + raise ValueError(f"interface.{field} must point to a PNG inside the plugin root") + name = path.as_posix() + try: + data = read_bytes(name) + except (OSError, KeyError) as error: + raise ValueError(f"interface.{field}: missing packaged asset {name}") from error + if len(data) > 5 * 1024 * 1024: + raise ValueError(f"interface.{field}: {name} exceeds 5 MiB") + if len(data) < 45 or data[:16] != b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR": + raise ValueError(f"interface.{field}: {name} is not a PNG with an IHDR header") + width, height = struct.unpack(">II", data[16:24]) + if width != height or not 48 <= width <= 4096: + raise ValueError(f"interface.{field}: {name} must be square and 48–4096 px") + if zlib.crc32(data[12:29]) != struct.unpack(">I", data[29:33])[0]: + raise ValueError(f"interface.{field}: {name} has an invalid PNG header checksum") + if data[-12:] != b"\x00\x00\x00\x00IEND\xaeB`\x82": + raise ValueError(f"interface.{field}: {name} is truncated") + files[name] = data + return files diff --git a/scripts/build-chatgpt-plugin.py b/scripts/build-chatgpt-plugin.py new file mode 100644 index 0000000..4a53c2e --- /dev/null +++ b/scripts/build-chatgpt-plugin.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Build a reproducible public plugin ZIP without changing the multi-host manifests.""" + +import argparse +import json +from pathlib import Path +import subprocess +import sys +from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo + +from branding_assets import branding_files + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--extension", action="store_true", + help="Use the native extension endpoint; requires the API release and feat_mcp_apps.", + ) + parser.add_argument("--host", choices=("chatgpt", "claude"), default="chatgpt") + args = parser.parse_args() + if args.host == "claude" and not args.extension: + parser.error("--host claude is an explicit management variant; use --extension too") + root = Path(__file__).resolve().parent.parent + subprocess.run([sys.executable, str(root / "scripts/check-manifests.py")], check=True) + + files = { + name: (root / name).read_bytes() + for name in (".codex-plugin/plugin.json", ".mcp.json", "assets/logo.svg", "LICENSE") + } + if args.extension: + mcp = json.loads(files[".mcp.json"]) + mcp["mcpServers"]["stackone"]["url"] = "https://mcp.stackone.com/mcp?extension=on&management-only=on" + mcp["mcpServers"]["stackone"]["note"] = "StackOne connection management. OAuth requires mcp, offline_access and mcp:manage. Entrypoint: stackone_open. No provider-action executor is exposed on this endpoint." + files[".mcp.json"] = (json.dumps(mcp, indent=2) + "\n").encode() + manifest = json.loads(files[".codex-plugin/plugin.json"]) + manifest["description"] = "Manage StackOne connector profiles, linked accounts and account linking." + manifest["keywords"] = ["stackone", "connections", "connector profiles", "account linking"] + manifest["skills"] = "./skills/" + manifest["extensions"] = {"com.openai": {"onboardingSkill": "./skills/setup/SKILL.md"}} + manifest["interface"]["shortDescription"] = "Manage your connections" + manifest["interface"]["longDescription"] = ( + "Manage StackOne connector profiles and linked accounts in ChatGPT. " + "Search accounts, review connection status, rename or enable profiles when permitted, " + "and link or reconnect accounts through the same StackOne Hub used by OAuth, embedded in ChatGPT. " + "Provider sign-in may require a popup. " + "Full credential configuration opens the existing StackOne dashboard. " + "Management requires explicit consent and follows your current StackOne permissions." + ) + manifest["interface"]["defaultPrompt"] = [ + "Open StackOne to review my linked accounts.", + "Open my StackOne connector profiles.", + "Connect Workday with StackOne.", + ] + files[".codex-plugin/plugin.json"] = (json.dumps(manifest, indent=2) + "\n").encode() + files["skills/setup/SKILL.md"] = (root / "extension/skills/setup/SKILL.md").read_bytes() + + # Resolve assets from the final manifest, including extension-mode edits. + interface = json.loads(files[".codex-plugin/plugin.json"])["interface"] + files.update(branding_files(interface, lambda name: (root / name).read_bytes())) + if args.host == "claude": + claude = json.loads((root / ".claude-plugin/plugin.json").read_text()) + claude["description"] = "Manage StackOne connector profiles, linked accounts and account linking." + claude["keywords"] = ["stackone", "connections", "connector profiles", "account linking"] + claude["skills"] = "./skills/" + files[".claude-plugin/plugin.json"] = (json.dumps(claude, indent=2) + "\n").encode() + del files[".codex-plugin/plugin.json"] + + suffix = "-extension" if args.extension else "" + destination = root / "dist" / f"stackone-{args.host}{suffix}.zip" + destination.parent.mkdir(exist_ok=True) + with ZipFile(destination, "w", compression=ZIP_DEFLATED) as archive: + for name, data in sorted(files.items()): + entry = ZipInfo(name, date_time=(2026, 1, 1, 0, 0, 0)) + entry.compress_type = ZIP_DEFLATED + entry.external_attr = 0o100644 << 16 + archive.writestr(entry, data) + with ZipFile(destination) as archive: + branding_files(interface, archive.read) + if archive.testzip() is not None: + raise ValueError("Plugin archive failed its integrity check") + print(destination) + + +if __name__ == "__main__": + main() diff --git a/scripts/check-manifests.py b/scripts/check-manifests.py index 2c3096b..e04a23e 100755 --- a/scripts/check-manifests.py +++ b/scripts/check-manifests.py @@ -6,6 +6,7 @@ across all of them, and is what CI runs. """ import json, sys, pathlib +from branding_assets import branding_files root = pathlib.Path(__file__).resolve().parent.parent def load(p): return json.loads((root / p).read_text()) @@ -45,6 +46,17 @@ def load(p): return json.loads((root / p).read_text()) if manifests[".codex-plugin/plugin.json"].get("mcpServers") != "./.mcp.json": errors.append(".codex-plugin/plugin.json must point mcpServers at ./.mcp.json") +interface = manifests[".codex-plugin/plugin.json"].get("interface", {}) +for key, limit in (("displayName", 30), ("shortDescription", 30), ("developerName", 80), ("longDescription", 4000)): + value = interface.get(key, "") + if not isinstance(value, str) or not value.strip() or len(value) > limit: + errors.append(f".codex-plugin/plugin.json: interface.{key} must contain 1–{limit} characters") + +try: + branding_files(interface, lambda name: (root / name).read_bytes()) +except ValueError as error: + errors.append(str(error)) + for path, mk in markets.items(): entry = mk["plugins"][0] if entry.get("name") != ref["name"]: diff --git a/scripts/test_branding_assets.py b/scripts/test_branding_assets.py new file mode 100644 index 0000000..4fb2433 --- /dev/null +++ b/scripts/test_branding_assets.py @@ -0,0 +1,75 @@ +"""Regression coverage for missing icon references and broken ZIP assets.""" + +import copy +import io +import json +from pathlib import Path +import struct +import unittest +from zipfile import ZipFile +import zlib + +from branding_assets import branding_files, ICON_FIELDS + +ROOT = Path(__file__).resolve().parent.parent + + +class BrandingAssetsTest(unittest.TestCase): + def setUp(self): + self.interface = json.loads((ROOT / ".codex-plugin/plugin.json").read_text())["interface"] + self.png = (ROOT / "assets/logo.png").read_bytes() + self.assets = {"assets/logo.png": self.png} + + def test_all_four_slots_resolve_to_packaged_official_logo(self): + result = branding_files(self.interface, self.assets.__getitem__) + self.assertEqual(result, self.assets) + self.assertEqual({self.interface[key] for key in ICON_FIELDS}, {"./assets/logo.png"}) + + def test_missing_field_cannot_silently_fall_back_to_generic_icon(self): + for key in ICON_FIELDS: + with self.subTest(key=key): + interface = copy.deepcopy(self.interface) + del interface[key] + with self.assertRaisesRegex(ValueError, key): + branding_files(interface, self.assets.__getitem__) + + def test_rejects_unbundled_and_external_paths(self): + for path in ["./assets/missing.png", "https://example.com/icon.png", "./../icon.png", "./C:\\icon.png"]: + with self.subTest(path=path): + interface = {**self.interface, "logo": path} + with self.assertRaises(ValueError): + branding_files(interface, self.assets.__getitem__) + + def test_rejects_non_images_truncation_and_bad_headers(self): + for data in [b"Not found", self.png[:-12], self.png[:29] + b"bad!" + self.png[33:]]: + with self.subTest(size=len(data)): + with self.assertRaises(ValueError): + branding_files(self.interface, lambda _: data) + + def test_rejects_undersized_non_square_and_oversized_icons(self): + for width, height in [(16, 16), (200, 100), (5000, 5000)]: + with self.subTest(size=(width, height)): + data = bytearray(self.png) + data[16:24] = struct.pack(">II", width, height) + data[29:33] = struct.pack(">I", zlib.crc32(data[12:29])) + with self.assertRaisesRegex(ValueError, "48–4096"): + branding_files(self.interface, lambda _: bytes(data)) + + def test_actual_zip_lookup_detects_missing_asset(self): + buffer = io.BytesIO() + with ZipFile(buffer, "w") as archive: + archive.writestr(".codex-plugin/plugin.json", "{}") + with ZipFile(buffer) as archive: + with self.assertRaisesRegex(ValueError, "missing packaged asset"): + branding_files(self.interface, archive.read) + + def test_actual_zip_roundtrip_preserves_asset_bytes(self): + buffer = io.BytesIO() + with ZipFile(buffer, "w") as archive: + archive.writestr("assets/logo.png", self.png) + with ZipFile(buffer) as archive: + self.assertEqual(branding_files(self.interface, archive.read), self.assets) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_extension_packages.py b/scripts/test_extension_packages.py new file mode 100644 index 0000000..f1686fb --- /dev/null +++ b/scripts/test_extension_packages.py @@ -0,0 +1,54 @@ +"""Exercise the actual distributable archives and preserve the existing action package.""" +import hashlib +import json +from pathlib import Path +import subprocess +import sys +import unittest +from zipfile import ZipFile + +ROOT = Path(__file__).resolve().parent.parent +ENDPOINT = "https://mcp.stackone.com/mcp?extension=on&management-only=on" + + +def build(*args): + result = subprocess.run( + [sys.executable, str(ROOT / "scripts/build-chatgpt-plugin.py"), *args], + check=True, capture_output=True, text=True, + ) + return Path(result.stdout.strip().splitlines()[-1]) + + +class ExtensionPackagesTest(unittest.TestCase): + def test_chatgpt_package_contains_setup_skill_and_bounded_endpoint(self): + path = build("--extension") + with ZipFile(path) as archive: + manifest = json.loads(archive.read(".codex-plugin/plugin.json")) + onboarding = manifest["extensions"]["com.openai"]["onboardingSkill"] + self.assertIn(onboarding.removeprefix("./"), archive.namelist()) + self.assertEqual(manifest["skills"], "./skills/") + self.assertEqual(json.loads(archive.read(".mcp.json"))["mcpServers"]["stackone"]["url"], ENDPOINT) + self.assertNotIn(".claude-plugin/plugin.json", archive.namelist()) + self.assertIsNone(archive.testzip()) + first = hashlib.sha256(path.read_bytes()).digest() + self.assertEqual(first, hashlib.sha256(build("--extension").read_bytes()).digest()) + + def test_claude_variant_uses_same_skill_and_endpoint_with_its_own_manifest(self): + path = build("--extension", "--host", "claude") + with ZipFile(path) as archive: + manifest = json.loads(archive.read(".claude-plugin/plugin.json")) + self.assertEqual(manifest["skills"], "./skills/") + self.assertIn("skills/setup/SKILL.md", archive.namelist()) + self.assertNotIn(".codex-plugin/plugin.json", archive.namelist()) + self.assertEqual(json.loads(archive.read(".mcp.json"))["mcpServers"]["stackone"]["url"], ENDPOINT) + self.assertIsNone(archive.testzip()) + + def test_default_action_package_and_source_installs_are_unchanged(self): + with ZipFile(build()) as archive: + self.assertEqual(json.loads(archive.read(".mcp.json"))["mcpServers"]["stackone"]["url"], "https://mcp.stackone.com/mcp") + self.assertNotIn("skills/setup/SKILL.md", archive.namelist()) + self.assertEqual(json.loads((ROOT / ".mcp.json").read_text())["mcpServers"]["stackone"]["url"], "https://mcp.stackone.com/mcp") + + +if __name__ == "__main__": + unittest.main()