From 1565a93380f0267b4414ec7a96c4d78105b49d16 Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 9 Aug 2026 21:52:51 +0000 Subject: [PATCH] fix(agentplugins): separate ChatGPT target lifecycle --- README.md | 18 +- .../{catalog-v1.json => catalog-v2.json} | 127 ++-- cli/plugin-kit-ai/cmd/agentplugins/main.go | 4 +- .../cmd/agentplugins/main_test.go | 5 +- .../internal/agentpluginscli/add.go | 28 +- .../internal/agentpluginscli/binding.go | 3 + .../internal/agentpluginscli/cli_test.go | 610 +++++++++++++++++- .../internal/agentpluginscli/lifecycle.go | 34 +- .../internal/agentpluginscli/read.go | 27 +- .../internal/agentpluginscli/root.go | 2 +- .../internal/agentpluginscli/source.go | 67 ++ .../agentpluginscli/state_migration.go | 6 +- .../agentplugins/adapters/catalog/catalog.go | 86 ++- .../adapters/catalog/catalog_test.go | 74 ++- .../adapters/clientdetect/detector.go | 33 +- .../adapters/clientdetect/detector_test.go | 47 +- .../agentplugins/adapters/loader/app.go | 107 +++ .../agentplugins/adapters/loader/loader.go | 88 ++- .../adapters/loader/loader_test.go | 164 +++++ .../agentplugins/adapters/loader/mcp.go | 104 +++ .../adapters/loader/openai_plugin.go | 182 ++++++ .../adapters/statemigration/migrate.go | 6 +- .../adapters/statemigration/migrate_test.go | 3 + .../adapters/statev2/legacy_v2.go | 123 ++++ .../agentplugins/adapters/statev2/store.go | 39 +- .../adapters/statev2/store_test.go | 145 +++-- .../agentplugins/domain/catalog.go | 15 +- .../agentplugins/domain/clients.go | 3 + .../agentplugins/domain/state.go | 14 +- .../agentplugins/domain/types.go | 26 + .../agentplugins/planner/planner.go | 75 ++- .../agentplugins/planner/planner_test.go | 102 +++ .../agentplugins/providers/activator.go | 27 +- .../agentplugins/providers/activator_test.go | 44 ++ .../agentplugins/providers/stager.go | 111 +++- .../agentplugins/providers/stager_test.go | 45 +- .../agentplugins/usecase/legacy_remove.go | 2 +- .../agentplugins/usecase/service.go | 26 +- .../agentplugins/usecase/service_test.go | 28 +- npm/agentplugins/README.md | 32 +- 40 files changed, 2475 insertions(+), 207 deletions(-) rename cli/plugin-kit-ai/cmd/agentplugins/{catalog-v1.json => catalog-v2.json} (87%) create mode 100644 install/integrationctl/agentplugins/adapters/loader/app.go create mode 100644 install/integrationctl/agentplugins/adapters/loader/openai_plugin.go create mode 100644 install/integrationctl/agentplugins/adapters/statev2/legacy_v2.go diff --git a/README.md b/README.md index b14641d3..8d094dd7 100644 --- a/README.md +++ b/README.md @@ -23,17 +23,25 @@ npx universal-agent-plugins add context7 ``` It reads the root `plugin.json` defined by Agent Plugins 1.0 and plans, prepares, -or installs one explicit target at a time across Codex/ChatGPT, Cursor, GitHub +or installs one explicit target at a time across Codex, ChatGPT, Cursor, GitHub Copilot/VS Code, and Kiro. v0.1 supports user scope; client-native limits are reported before mutation. The first catalog contains [26 portable plugins](https://github.com/777genius/universal-agent-plugins). -The portable manifest is the root `plugin.json`; optional portable components -such as `mcp.json` and skills remain package inputs. For the Codex target, the -CLI generates the official Codex package manifest at `.codex-plugin/plugin.json`. +Accepted source shapes are either a portable root `plugin.json` with optional +`mcp.json`, `.app.json`, and `skills/`, or an official `.codex-plugin/plugin.json` +with its declared root `.mcp.json`, `.app.json`, and `skills/` inputs. A portable +root manifest wins when both exist. Codex and ChatGPT retain bundled `.mcp.json`; +ChatGPT MCP additionally requires a valid `.app.json` mapping to a connection +registered in Developer Mode. Both targets receive an official generated +`.codex-plugin/plugin.json`. +Starting with agentplugins 0.1.6, direct package sources can use `--target +chatgpt`; catalog short names stay fail-closed unless their catalog v2 entry +includes pinned ChatGPT compatibility evidence. Catalog v1 remains readable +without ChatGPT binding metadata. When GitHub Copilot CLI is detected, `agentplugins` installs, updates, and removes the plugin automatically through a managed local marketplace. VS Code discovers that installation automatically, so selecting either Copilot or VS -Code once is enough. Codex/ChatGPT and Kiro keep their +Code once is enough. Codex, ChatGPT, and Kiro keep their required client confirmation and receive an exact, path-specific next step in human-readable output. diff --git a/cli/plugin-kit-ai/cmd/agentplugins/catalog-v1.json b/cli/plugin-kit-ai/cmd/agentplugins/catalog-v2.json similarity index 87% rename from cli/plugin-kit-ai/cmd/agentplugins/catalog-v1.json rename to cli/plugin-kit-ai/cmd/agentplugins/catalog-v2.json index 94447c53..52657440 100644 --- a/cli/plugin-kit-ai/cmd/agentplugins/catalog-v1.json +++ b/cli/plugin-kit-ai/cmd/agentplugins/catalog-v2.json @@ -1,18 +1,18 @@ { - "$schema": "https://github.com/777genius/universal-agent-plugins/schemas/catalog-v1.schema.json", - "schema_version": 1, - "catalog_version": "0.1.0", + "$schema": "https://github.com/777genius/universal-agent-plugins/schemas/catalog-v2.schema.json", + "schema_version": 2, + "catalog_version": "0.2.0", "repository": "777genius/universal-agent-plugins", - "revision": "85bdea44799f2970c20b51d675f6d365fee3dbef", - "published_at": "2026-08-08T18:46:47Z", + "revision": "2ddbb99dd190c1792b79904f9875e6322bccd243", + "published_at": "2026-08-09T22:28:33Z", "plugins": [ { "name": "agent-code-navigator", "version": "0.1.0", "agent_plugins_schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "minimum_cli_version": "0.1.0", + "minimum_cli_version": "0.1.6", "source_path": "plugins/agent-code-navigator", - "tree_digest": "sha256:9ea3846fd168322f46da9397ee2c6068eb53cebfca6903d0c3db256d77e38175", + "tree_digest": "sha256:2da970986164b2ba56aee452205bb2312927814f66596653b248c127e4e767e0", "manifest_digest": "sha256:675fb48dff9431f75e2986211039b3bfb04860a84d6a5bd157b9658514a2394e", "components": [ "skills" @@ -49,9 +49,9 @@ "name": "atlassian", "version": "0.1.0", "agent_plugins_schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "minimum_cli_version": "0.1.0", + "minimum_cli_version": "0.1.6", "source_path": "plugins/atlassian", - "tree_digest": "sha256:17266edb0d757c14adc0c3ba07cbfc8a1b5b648f292b1ea799ad087df9a3b3af", + "tree_digest": "sha256:a5988dbe73fa2efa8892a3918aabc6273a8398d56f3d247e7e921fdb1ee8a167", "manifest_digest": "sha256:e83ade754f1251554622e4b48d596ebf72510bd7292b19a7793df4de8e546d04", "components": [ "mcp" @@ -88,9 +88,9 @@ "name": "chrome-devtools", "version": "0.1.0", "agent_plugins_schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "minimum_cli_version": "0.1.0", + "minimum_cli_version": "0.1.6", "source_path": "plugins/chrome-devtools", - "tree_digest": "sha256:ef9f503ec6a6d34f36fb13ee17376c69f578be15896aa49fdd4c5164925f297d", + "tree_digest": "sha256:07cae09debbcd3f4ab7c18ef00db5fba58b0185b2c502d759732155e47a65f7b", "manifest_digest": "sha256:e7d43a8e39b0e83f2c05777e297f6a3884002dc2601d70528b55a44132db8091", "components": [ "mcp" @@ -127,9 +127,9 @@ "name": "cloudflare", "version": "0.1.0", "agent_plugins_schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "minimum_cli_version": "0.1.0", + "minimum_cli_version": "0.1.6", "source_path": "plugins/cloudflare", - "tree_digest": "sha256:dc93797949592c138bda0592d13a09b9d07158e333a9df2c4beaec7d568a3f75", + "tree_digest": "sha256:788761d3f9da6d95d7ab8c6253942f057ecef5e0fb8e2815fbca8dcbb5fcee40", "manifest_digest": "sha256:9bec7cfed2ecb217145c2a02658296670a328416566433518b4da0246e91d382", "components": [ "mcp" @@ -166,9 +166,9 @@ "name": "cloudflare-bindings", "version": "0.1.0", "agent_plugins_schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "minimum_cli_version": "0.1.0", + "minimum_cli_version": "0.1.6", "source_path": "plugins/cloudflare-bindings", - "tree_digest": "sha256:833653c9b11a8242a7f0e115b2d101feb595937c4fde93708f5471cabcfcc8b6", + "tree_digest": "sha256:98573830f703a479dece5bb964353117151e533abff0fa77597c5a654dc56729", "manifest_digest": "sha256:6b945cf035f784216e71425cf7b6ca477b27a2069d2580e9713a65f5ec2ca0bf", "components": [ "mcp" @@ -205,9 +205,9 @@ "name": "cloudflare-docs", "version": "0.1.0", "agent_plugins_schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "minimum_cli_version": "0.1.0", + "minimum_cli_version": "0.1.6", "source_path": "plugins/cloudflare-docs", - "tree_digest": "sha256:d2a0a22ababf569198a742c18ede0fe6037f7a0bc49f2d674d936310cf057ff5", + "tree_digest": "sha256:ebfc3239ab774dea35897919362f4ca0b2b3bfc6d33863d977940ab493907b74", "manifest_digest": "sha256:6b575562e527194ccd31238fde8c5f764409ecc98e211e92dfb81455eef88bdd", "components": [ "mcp" @@ -237,6 +237,19 @@ "package": "native", "verification": "tested", "authentication": "not_required" + }, + "chatgpt": { + "package": "projected", + "verification": "tested", + "authentication": "not_required", + "app_binding": { + "app_key": "cloudflare-docs", + "id": "plugin_asdk_app_6a78e90cf73481918ef10cdb87cd4bb4", + "mcp_server": "cloudflare-docs", + "mcp_url": "https://docs.mcp.cloudflare.com/mcp", + "runtime_evidence": "tests/e2e/results/chatgpt-cloudflare-docs-personal-app-2026-08-10.json", + "runtime_evidence_revision": "2ddbb99dd190c1792b79904f9875e6322bccd243" + } } } }, @@ -244,9 +257,9 @@ "name": "cloudflare-observability", "version": "0.1.0", "agent_plugins_schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "minimum_cli_version": "0.1.0", + "minimum_cli_version": "0.1.6", "source_path": "plugins/cloudflare-observability", - "tree_digest": "sha256:5a4dcb7e0de469cffad69b223c0ce95e6fae3760c3796e0391768838d1c7d676", + "tree_digest": "sha256:dbabe94312d1d780f6f96c6f9c595cb46dbdef49ce31182906c3cc4077e4b295", "manifest_digest": "sha256:41573bb020202619d72fe2cbbf35c56cb5ed4b885b17867ffd2e4a74ebcb5064", "components": [ "mcp" @@ -283,9 +296,9 @@ "name": "cloudflare-radar", "version": "0.1.0", "agent_plugins_schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "minimum_cli_version": "0.1.0", + "minimum_cli_version": "0.1.6", "source_path": "plugins/cloudflare-radar", - "tree_digest": "sha256:765aad1d8742a4f9c74662f67d696d94a0ce6e3ff1214b22e2abaa81868a540c", + "tree_digest": "sha256:bad20c22343458ad40d08b48d621db32955b07ab66d61ad02cc0546a4e5e94c6", "manifest_digest": "sha256:cf94d10bad7d902f4ed171d2363854fb93409ac79a1c5e02d152be6448d66fc2", "components": [ "mcp" @@ -322,9 +335,9 @@ "name": "context7", "version": "0.1.0", "agent_plugins_schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "minimum_cli_version": "0.1.0", + "minimum_cli_version": "0.1.6", "source_path": "plugins/context7", - "tree_digest": "sha256:e4f1a4cd941b03743869a94ecafe6a1aded9c991c5c09eceffdcb708dfc953de", + "tree_digest": "sha256:51eee528879504843a31b2ea57abb77167e33cd5b81856e0640ce3a52f3281a4", "manifest_digest": "sha256:341c54728c2f50d3876b12a6e94ba06751c94d2be215037846d06f93a018434e", "components": [ "mcp" @@ -361,9 +374,9 @@ "name": "docker-hub", "version": "0.1.0", "agent_plugins_schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "minimum_cli_version": "0.1.0", + "minimum_cli_version": "0.1.6", "source_path": "plugins/docker-hub", - "tree_digest": "sha256:5f7a893b6368ecd82195907e091ac63ea7288247a609fd5cea4b8b5c6ef6b69d", + "tree_digest": "sha256:7976d176756b199da384cc898ccfe24aa7c0ebd9894e703132fec6d1bac699a4", "manifest_digest": "sha256:413347fe45d0dbcbd5b4d5cf88eb7e0ec5c0cd080486ea4cb8c3f61f0bd7a95d", "components": [ "mcp" @@ -400,9 +413,9 @@ "name": "figma", "version": "0.1.0", "agent_plugins_schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "minimum_cli_version": "0.1.0", + "minimum_cli_version": "0.1.6", "source_path": "plugins/figma", - "tree_digest": "sha256:5e5ce333c6f213970d0c92196b596eb1a0705ce7a76fdc93c60c143b53b1d20d", + "tree_digest": "sha256:aefbb22c2052bb401168b4ceea895f5d4eeb9073185c4d33a91f4b38ebad24b4", "manifest_digest": "sha256:67fbfd8062a7f3d965ee63cc68f392a6afb1e8861e3bcc5fe1f70e7e9fadabba", "components": [ "mcp" @@ -444,9 +457,9 @@ "name": "firebase", "version": "0.1.0", "agent_plugins_schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "minimum_cli_version": "0.1.0", + "minimum_cli_version": "0.1.6", "source_path": "plugins/firebase", - "tree_digest": "sha256:e5d1c691e4a409b7297b53fee6581850c4fc9adff1b261cda7976e5b02ceee97", + "tree_digest": "sha256:9ed143367d27e63b55acd864135680ac71137247d4cb17599bf94370c9cc32fc", "manifest_digest": "sha256:9883d7c5b0472304f799df862f52d3cdae3120b3324633d6a58fceceb78da7b7", "components": [ "mcp" @@ -483,9 +496,9 @@ "name": "github", "version": "0.1.0", "agent_plugins_schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "minimum_cli_version": "0.1.0", + "minimum_cli_version": "0.1.6", "source_path": "plugins/github", - "tree_digest": "sha256:c0ac7e6b561ea073ad174aa9e127841d6c8878e604fde861d0fbd0d6a2960865", + "tree_digest": "sha256:8977d2273c14e2cfcd0d502d490c8420e3edb2822608af2aeb7493fca7703d27", "manifest_digest": "sha256:091e37c5f33c0a92f77070cfcb1b03759b2637927281c04d989e6c8fef888cae", "components": [ "mcp" @@ -527,9 +540,9 @@ "name": "gitlab", "version": "0.1.0", "agent_plugins_schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "minimum_cli_version": "0.1.0", + "minimum_cli_version": "0.1.6", "source_path": "plugins/gitlab", - "tree_digest": "sha256:0495772011f6a810fe89876f5970c7bbc5142fffb873b2fee65b4de0573f8177", + "tree_digest": "sha256:e7ef8870fe6678ab84731a0b389b57b41a439e4903ea37b4fe60b59240c08bc3", "manifest_digest": "sha256:1a181fab20d9520f5f006f4237e88d67cf58d29b9994dbc45ee4c80d2474e080", "components": [ "mcp" @@ -566,9 +579,9 @@ "name": "greptile", "version": "0.1.0", "agent_plugins_schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "minimum_cli_version": "0.1.0", + "minimum_cli_version": "0.1.6", "source_path": "plugins/greptile", - "tree_digest": "sha256:d5ae01ce0f88f6a0e74ae1374fc8762f2180550916314f30e6e1e2c3ee149da3", + "tree_digest": "sha256:b6c701696326d50ca19664f2e6350451a75e37298f2ae47e107ccaf6f865d93d", "manifest_digest": "sha256:05b29500564aede5274a6d21c0cf5bb4727e995045b04de170913b7ef2ae07bb", "components": [ "mcp" @@ -605,9 +618,9 @@ "name": "heroku", "version": "0.1.0", "agent_plugins_schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "minimum_cli_version": "0.1.0", + "minimum_cli_version": "0.1.6", "source_path": "plugins/heroku", - "tree_digest": "sha256:f05e7b102072a201cd2b4ff82300bedb0a863f279a4cb7f315b0b8ee077dcb66", + "tree_digest": "sha256:9988a94c56676b54f5e819d4c72eefe7164a6e207503c32308122983a9662d64", "manifest_digest": "sha256:5b7c088e290ed04b0eafe0275dc806e7c2153dd7cbe6b74d1c5f1d2961e9355a", "components": [ "mcp" @@ -644,9 +657,9 @@ "name": "hubspot-crm", "version": "0.1.0", "agent_plugins_schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "minimum_cli_version": "0.1.0", + "minimum_cli_version": "0.1.6", "source_path": "plugins/hubspot-crm", - "tree_digest": "sha256:5ec6335b78a6b97fab797c5302722b2e911fd67a10ef169c5c844d34f3ca3396", + "tree_digest": "sha256:93db3068e880bc135100853804f195434673fc5dd6bcc9a7fd82197e930dda4e", "manifest_digest": "sha256:20334e86a1b599d2759e04bfaef37dc2ddb7f6b0f3994b5d66d4eca12fbec941", "components": [ "mcp" @@ -683,9 +696,9 @@ "name": "hubspot-developer", "version": "0.1.0", "agent_plugins_schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "minimum_cli_version": "0.1.0", + "minimum_cli_version": "0.1.6", "source_path": "plugins/hubspot-developer", - "tree_digest": "sha256:721ac10f15755591601eb13819620be582da922225469e952e6cc8a7585e854f", + "tree_digest": "sha256:706abd93939ee72ea62fd2da90ee27f6d55cfa5c5d57fa695d5b0bcbc4f99a75", "manifest_digest": "sha256:257a75ed2e473945a26afa102152f866bd8269bb41c998f6d87fbdf43ed1a6b9", "components": [ "mcp" @@ -722,9 +735,9 @@ "name": "linear", "version": "0.1.0", "agent_plugins_schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "minimum_cli_version": "0.1.0", + "minimum_cli_version": "0.1.6", "source_path": "plugins/linear", - "tree_digest": "sha256:b443c5b4a6cc11be57f7bac9de744066846276d4cd5922bd02818d8a76afb2d4", + "tree_digest": "sha256:ae5a6bee33aff8bc2a045cc8f7ac4f7cf0ae453c0bb8031a587bb2a9b081a2a2", "manifest_digest": "sha256:0b8ed223c21d206d802130ace7219b5c78af1903675640cc77f5195e4f86ef70", "components": [ "mcp" @@ -766,9 +779,9 @@ "name": "neon", "version": "0.1.0", "agent_plugins_schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "minimum_cli_version": "0.1.0", + "minimum_cli_version": "0.1.6", "source_path": "plugins/neon", - "tree_digest": "sha256:980b66094b63f3ec073864db27a3f0b40d497917487dbc2f594bad46fadf12ef", + "tree_digest": "sha256:d4a807220a40563e968f8efeb37a424b95ab82c6566877f1f6d688c63900ad10", "manifest_digest": "sha256:6787894c7d3e033db05deee5e36358caf387493a98a78716d6db4b343e9c0698", "components": [ "mcp" @@ -805,9 +818,9 @@ "name": "notion", "version": "0.1.0", "agent_plugins_schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "minimum_cli_version": "0.1.0", + "minimum_cli_version": "0.1.6", "source_path": "plugins/notion", - "tree_digest": "sha256:73b180af71006641968249c81795b4315a380f589d90f9943d894297e09a0c4d", + "tree_digest": "sha256:67b355127dc3e618196b0f8a9f1af1914f6b9b8f1e31299dc75d567c010e8da9", "manifest_digest": "sha256:bbab30a4f062218d7a5fce2f6c818680d3c7adfaebdbaa3e523e15942a2f57e5", "components": [ "mcp" @@ -849,9 +862,9 @@ "name": "sentry", "version": "0.1.0", "agent_plugins_schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "minimum_cli_version": "0.1.0", + "minimum_cli_version": "0.1.6", "source_path": "plugins/sentry", - "tree_digest": "sha256:f05770b0d784df29d25f5f5365687301085086730d817fa7a064876dead80f54", + "tree_digest": "sha256:d67605b84b3b3d9a1791307a40b5df663e2bb719b1321a3ee2899977fb849762", "manifest_digest": "sha256:7606b1f8af700ea76590f0ae586ff8586f4dc370f536ceedbeab8d934494e42e", "components": [ "mcp" @@ -888,9 +901,9 @@ "name": "statsig", "version": "0.1.0", "agent_plugins_schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "minimum_cli_version": "0.1.0", + "minimum_cli_version": "0.1.6", "source_path": "plugins/statsig", - "tree_digest": "sha256:a51a09363253f4c8a593fef01b6816ae7efc953bfd23661a0a4417374fe34e4e", + "tree_digest": "sha256:28fd20bf16a96e87f311ecac07ab89f75e23c42b303c931fa2bbc9840005db73", "manifest_digest": "sha256:5100526dca8f8e53e42d1d53f8acc1879ea8a2d8b0982df3f00be5e95bf3e465", "components": [ "mcp" @@ -927,9 +940,9 @@ "name": "stripe", "version": "0.1.0", "agent_plugins_schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "minimum_cli_version": "0.1.0", + "minimum_cli_version": "0.1.6", "source_path": "plugins/stripe", - "tree_digest": "sha256:68471c6c4fe88b35c3ea9eaff5d80b2d0b9a643db1e1458c95433e65b182a7f0", + "tree_digest": "sha256:63cba08ab98fc8c0d91db9f580390f39551fddd3ffdff56443f928687dd920fd", "manifest_digest": "sha256:e70885fda5a4ce50c302891229e204bbf22904ec3f227670e5916d69f9b8b80d", "components": [ "mcp" @@ -966,9 +979,9 @@ "name": "supabase", "version": "0.1.0", "agent_plugins_schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "minimum_cli_version": "0.1.0", + "minimum_cli_version": "0.1.6", "source_path": "plugins/supabase", - "tree_digest": "sha256:e5c7a17e9dae43f15224243d75e58ed6b18c386f22ad7f264e047c557796278e", + "tree_digest": "sha256:2ae36c14a87016fad5ea9a45bd0a16b6f56acf15e64604cb0de942198ef35277", "manifest_digest": "sha256:bb0672e8cd411b1c8e0872a5f7f0abc95f9c68f008802d42a2cd16a689deeb11", "components": [ "mcp" @@ -1005,9 +1018,9 @@ "name": "vercel", "version": "0.1.0", "agent_plugins_schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "minimum_cli_version": "0.1.0", + "minimum_cli_version": "0.1.6", "source_path": "plugins/vercel", - "tree_digest": "sha256:2a1560a7c24263b2ca9176d4a3a1d2fc9443ac539a9bc852ef7d767a386ea4ff", + "tree_digest": "sha256:4ed31025a6c8323a330a163190c6264f0a6cebde8f643b87d599d8b5c6745304", "manifest_digest": "sha256:23f7cd28573c4ab10943178db05a47f2af3639518a334449c23d89b19562ef74", "components": [ "mcp" diff --git a/cli/plugin-kit-ai/cmd/agentplugins/main.go b/cli/plugin-kit-ai/cmd/agentplugins/main.go index 53630e5b..4c2acb4e 100644 --- a/cli/plugin-kit-ai/cmd/agentplugins/main.go +++ b/cli/plugin-kit-ai/cmd/agentplugins/main.go @@ -30,8 +30,8 @@ import ( var ( version = "0.1.0-development" defaultCatalogURL = "" - defaultCatalogDigest = "sha256:b1e3efcdc7bd3fc5cc64c959f96964818b1cbfd815cbd125045c02757c767c30" - //go:embed catalog-v1.json + defaultCatalogDigest = "sha256:66199c87bd68c65e39d15aa2c5c6e6c7830c9b116d8ed3590123031b32357050" + //go:embed catalog-v2.json embeddedCatalog []byte ) diff --git a/cli/plugin-kit-ai/cmd/agentplugins/main_test.go b/cli/plugin-kit-ai/cmd/agentplugins/main_test.go index 1dc791b2..74d0174c 100644 --- a/cli/plugin-kit-ai/cmd/agentplugins/main_test.go +++ b/cli/plugin-kit-ai/cmd/agentplugins/main_test.go @@ -15,10 +15,13 @@ func TestEmbeddedCatalogIsPinnedAndContainsAllLaunchPlugins(t *testing.T) { if digest != defaultCatalogDigest { t.Fatalf("embedded catalog digest = %s, want %s", digest, defaultCatalogDigest) } - loaded, err := (catalog.Loader{CurrentCLIVersion: "0.1.0"}).Load(embeddedCatalog, defaultCatalogDigest) + loaded, err := (catalog.Loader{CurrentCLIVersion: "0.1.6"}).Load(embeddedCatalog, defaultCatalogDigest) if err != nil { t.Fatal(err) } + if loaded.Catalog.SchemaVersion != 2 { + t.Fatalf("embedded catalog schema_version = %d, want 2", loaded.Catalog.SchemaVersion) + } if len(loaded.Catalog.Plugins) != 26 { t.Fatalf("catalog plugins = %d", len(loaded.Catalog.Plugins)) } diff --git a/cli/plugin-kit-ai/internal/agentpluginscli/add.go b/cli/plugin-kit-ai/internal/agentpluginscli/add.go index 7a924617..0553ab76 100644 --- a/cli/plugin-kit-ai/internal/agentpluginscli/add.go +++ b/cli/plugin-kit-ai/internal/agentpluginscli/add.go @@ -53,6 +53,9 @@ func runAdd(ctx context.Context, cmd *cobra.Command, app App, opts *options, sou if err != nil { return err } + if err := prepareLoadedPackageForClient(&loaded, selected.ClientID); err != nil { + return err + } planner := clientplanner.Planner{ManagedRoot: app.ManagedRoot, Detected: detectedMap} service := usecase.Service{ StateStore: app.StateStore, @@ -72,6 +75,15 @@ func runAdd(ctx context.Context, cmd *cobra.Command, app App, opts *options, sou } planned, err := service.Add(ctx, input) if err != nil { + if planned.Plan.Status == domain.PlanUnsupported { + if opts.format == "json" { + if renderErr := renderAddResult(cmd.OutOrStdout(), opts.format, loaded.envelope, planned, opts.dryRun); renderErr != nil { + return renderErr + } + } else if renderErr := renderHumanPlan(cmd.OutOrStdout(), loaded.envelope, planned); renderErr != nil { + return renderErr + } + } return err } if opts.dryRun || planned.NoChange { @@ -191,14 +203,22 @@ func selectClient( } target := normalizeTarget(opts.target) if target != "" { + if strings.EqualFold(strings.TrimSpace(opts.target), "openai") { + return domain.DetectedClient{}, detectedMap, fmt.Errorf("target %q is ambiguous; use --target codex or --target chatgpt", opts.target) + } client, ok := detectedMap[target] - if !ok || client.Status != domain.DetectionDetected { + if !ok && target == domain.ClientChatGPT { + client = domain.DetectedClient{ClientID: domain.ClientChatGPT, DisplayName: "ChatGPT", Status: domain.DetectionNotDetected} + detectedMap[target] = client + ok = true + } + if !ok || (client.Status != domain.DetectionDetected && target != domain.ClientChatGPT) { return domain.DetectedClient{}, detectedMap, fmt.Errorf("target %q was not detected", opts.target) } return client, detectedMap, nil } if len(detected) == 0 { - return domain.DetectedClient{}, detectedMap, fmt.Errorf("no supported AI client was detected; use --target after installing a client") + return domain.DetectedClient{}, detectedMap, fmt.Errorf("no supported local AI client was detected; use --target chatgpt for ChatGPT, or install/detect another client") } if len(detected) == 1 { return detected[0], detectedMap, nil @@ -225,8 +245,10 @@ func selectClient( func normalizeTarget(value string) domain.ClientID { switch strings.ToLower(strings.TrimSpace(value)) { - case "openai", "codex": + case "codex": return domain.ClientCodex + case "chatgpt": + return domain.ClientChatGPT case "cursor": return domain.ClientCursor case "copilot", "github-copilot": diff --git a/cli/plugin-kit-ai/internal/agentpluginscli/binding.go b/cli/plugin-kit-ai/internal/agentpluginscli/binding.go index bc7325aa..a06ce028 100644 --- a/cli/plugin-kit-ai/internal/agentpluginscli/binding.go +++ b/cli/plugin-kit-ai/internal/agentpluginscli/binding.go @@ -186,6 +186,9 @@ func componentInventoryLabel(inventory domain.ComponentInventory) string { } else { parts = append(parts, "mcp=absent") } + if inventory.AppPresent { + parts = append(parts, "apps=["+sortedList(inventory.AppBindings)+"]") + } parts = append(parts, "skills=["+sortedList(inventory.Skills)+"]") parts = append(parts, "extensions=["+sortedList(inventory.Extensions)+"]") if len(inventory.InvalidMCPServer) > 0 { diff --git a/cli/plugin-kit-ai/internal/agentpluginscli/cli_test.go b/cli/plugin-kit-ai/internal/agentpluginscli/cli_test.go index 00175a19..ab485116 100644 --- a/cli/plugin-kit-ai/internal/agentpluginscli/cli_test.go +++ b/cli/plugin-kit-ai/internal/agentpluginscli/cli_test.go @@ -37,6 +37,9 @@ func TestHelpKeepsAutomationConfirmationFlagOutOfUserFlow(t *testing.T) { if strings.Contains(stdout, "--yes") { t.Fatalf("user-facing help exposed the automation-only flag: %s", stdout) } + if !strings.Contains(stdout, "codex, chatgpt, cursor") { + t.Fatalf("user-facing help omitted the distinct ChatGPT target: %s", stdout) + } } func TestAddListAndInfoProduceVersionedPathRedactedJSON(t *testing.T) { @@ -765,10 +768,539 @@ func TestRepairReturnsOutputErrors(t *testing.T) { } } -func TestChatGPTTargetIsNotAliasedToCodexCLI(t *testing.T) { +func TestChatGPTTargetIsDistinctFromCodex(t *testing.T) { + t.Parallel() + if got := normalizeTarget("chatgpt"); got != domain.ClientChatGPT { + t.Fatalf("ChatGPT target = %s", got) + } +} + +func TestChatGPTDoctorDoesNotApplyAnotherTargetsNewerInventory(t *testing.T) { + t.Parallel() + installation := domain.Installation{ + Source: domain.SourceBinding{TreeDigest: "sha256:new-tree"}, + Package: domain.PackageBinding{ManifestDigest: "sha256:new-manifest", Inventory: domain.ComponentInventory{MCPPresent: true}}, + } + binding := domain.ClientBinding{PackageRevision: &domain.ClientPackageRevision{TreeDigest: "sha256:chatgpt-tree", ManifestDigest: "sha256:chatgpt-manifest"}} + if packageInventoryAppliesToBinding(installation, binding) { + t.Fatal("newer package inventory was applied to an older ChatGPT client revision") + } +} + +func TestOpenAITargetFailsAsAmbiguousWithoutMutation(t *testing.T) { + t.Parallel() + fixture := newCLIFixture(t, []domain.DetectedClient{fixtureClient(t, domain.ClientCodex)}) + plugin := writeCLIPlugin(t) + if _, _, err := fixture.execute(false, "add", plugin, "--target", "openai"); err == nil || !strings.Contains(err.Error(), "ambiguous") { + t.Fatalf("openai target error = %v", err) + } + state, err := fixture.store.Load() + if err != nil || len(state.Installations) != 0 { + t.Fatalf("ambiguous target mutated state: %+v, %v", state, err) + } +} + +func TestChatGPTMCPWithoutAppBindingFailsBeforeMutation(t *testing.T) { + t.Parallel() + fixture := newCLIFixture(t, []domain.DetectedClient{{ClientID: domain.ClientChatGPT, DisplayName: "ChatGPT", Status: domain.DetectionNotDetected}}) + plugin := writeCLIPlugin(t) + writeCLIMCP(t, plugin) + stdout, _, err := fixture.execute(false, "add", plugin, "--target", "chatgpt") + if err == nil || !strings.Contains(err.Error(), "Developer Mode") || !strings.Contains(err.Error(), ".app.json") { + t.Fatalf("missing app error = %v", err) + } + if !strings.Contains(stdout, "Developer Mode") || !strings.Contains(stdout, ".app.json") || !strings.Contains(stdout, "demo") { + t.Fatalf("human unsupported plan omitted recovery guidance: %s", stdout) + } + state, err := fixture.store.Load() + if err != nil || len(state.Installations) != 0 { + t.Fatalf("missing app mutated state: %+v, %v", state, err) + } + if _, err := os.Stat(fixture.app.ManagedRoot); !os.IsNotExist(err) { + t.Fatalf("unsupported plan mutated managed filesystem: %v", err) + } +} + +func TestChatGPTUnsupportedPlanRendersStructuredJSONGuidance(t *testing.T) { + t.Parallel() + fixture := newCLIFixture(t, []domain.DetectedClient{{ClientID: domain.ClientChatGPT, DisplayName: "ChatGPT", Status: domain.DetectionNotDetected}}) + plugin := writeCLIPlugin(t) + writeCLIMCP(t, plugin) + stdout, _, err := fixture.execute(false, "add", plugin, "--target", "chatgpt", "--dry-run", "--format", "json") + if err == nil { + t.Fatal("unsupported ChatGPT dry-run succeeded") + } + if !strings.Contains(stdout, `"status":"unsupported"`) || !strings.Contains(stdout, `"user_actions"`) || !strings.Contains(stdout, "Developer Mode") || !strings.Contains(stdout, ".app.json") { + t.Fatalf("JSON unsupported plan omitted structured guidance: %s", stdout) + } + state, stateErr := fixture.store.Load() + if stateErr != nil || len(state.Installations) != 0 { + t.Fatalf("unsupported JSON plan mutated state: %+v, %v", state, stateErr) + } +} + +func TestChatGPTUnsupportedUpdateRendersRecoveryWithoutMutation(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + format string + }{ + {name: "human", format: "human"}, + {name: "json", format: "json"}, + } { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fixture := newCLIFixture(t, nil) + plugin := writeCLIPlugin(t) + writeCLIMCP(t, plugin) + writeCLIApp(t, plugin) + if _, _, err := fixture.execute(false, "add", plugin, "--target", "chatgpt"); err != nil { + t.Fatal(err) + } + state, err := fixture.store.Load() + if err != nil { + t.Fatal(err) + } + binding := onlyCLIClient(state.Installations[0]) + stateBefore, err := os.ReadFile(fixture.store.Path) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(plugin, ".app.json")); err != nil { + t.Fatal(err) + } + args := []string{"update", "demo", "--target", "chatgpt"} + if test.format == "json" { + args = append(args, "--format", "json") + } + stdout, _, updateErr := fixture.execute(false, args...) + if updateErr == nil { + t.Fatal("unsupported ChatGPT update succeeded") + } + if test.format == "json" { + assertVersionedJSON(t, stdout, "update") + if !strings.Contains(stdout, `"user_actions"`) { + t.Fatalf("JSON update omitted structured user actions: %s", stdout) + } + } + for _, expected := range []string{"Developer Mode", ".app.json", "demo"} { + if !strings.Contains(stdout, expected) || !strings.Contains(updateErr.Error(), expected) { + t.Fatalf("%s update omitted %q recovery guidance: stdout=%q error=%v", test.format, expected, stdout, updateErr) + } + } + stateAfter, err := os.ReadFile(fixture.store.Path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(stateBefore, stateAfter) { + t.Fatal("unsupported ChatGPT update mutated state") + } + if _, err := os.Stat(filepath.Join(binding.TargetLocator, ".app.json")); err != nil { + t.Fatalf("unsupported ChatGPT update mutated the managed package: %v", err) + } + }) + } +} + +func TestChatGPTUnsupportedRepairRendersStructuredRecoveryWithoutMutation(t *testing.T) { + t.Parallel() + fixture := newCLIFixture(t, nil) + plugin := writeCLIPlugin(t) + writeCLIMCP(t, plugin) + writeCLIApp(t, plugin) + if _, _, err := fixture.execute(false, "add", plugin, "--target", "chatgpt"); err != nil { + t.Fatal(err) + } + state, err := fixture.store.Load() + if err != nil { + t.Fatal(err) + } + binding := onlyCLIClient(state.Installations[0]) + if err := os.Remove(filepath.Join(plugin, ".app.json")); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(binding.TargetLocator); err != nil { + t.Fatal(err) + } + stateBefore, err := os.ReadFile(fixture.store.Path) + if err != nil { + t.Fatal(err) + } + stdout, _, repairErr := fixture.execute(false, "repair", "demo", "--target", "chatgpt", "--format", "json") + if repairErr == nil { + t.Fatal("unsupported ChatGPT repair succeeded") + } + assertVersionedJSON(t, stdout, "repair") + for _, expected := range []string{`"user_actions"`, "Developer Mode", ".app.json", "demo"} { + if !strings.Contains(stdout, expected) { + t.Fatalf("JSON repair omitted %q recovery guidance: %s", expected, stdout) + } + } + stateAfter, err := os.ReadFile(fixture.store.Path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(stateBefore, stateAfter) { + t.Fatal("unsupported ChatGPT repair mutated state") + } + if _, err := os.Stat(binding.TargetLocator); !os.IsNotExist(err) { + t.Fatalf("unsupported ChatGPT repair mutated the managed package: %v", err) + } +} + +func TestNoDetectedClientSuggestsExplicitChatGPTTarget(t *testing.T) { t.Parallel() - if got := normalizeTarget("chatgpt"); got == domain.ClientCodex { - t.Fatalf("ChatGPT GUI target was aliased to Codex CLI: %s", got) + fixture := newCLIFixture(t, nil) + plugin := writeCLIPlugin(t) + _, _, err := fixture.execute(true, "add", plugin) + if err == nil || !strings.Contains(err.Error(), "--target chatgpt") || !strings.Contains(err.Error(), "install/detect another client") { + t.Fatalf("zero-client guidance = %v", err) + } + state, stateErr := fixture.store.Load() + if stateErr != nil || len(state.Installations) != 0 { + t.Fatalf("zero-client add mutated state: %+v, %v", state, stateErr) + } +} + +func TestCatalogChatGPTAppBindingVerifiesMCPAndSynthesizesApp(t *testing.T) { + t.Parallel() + fixture := newCLIFixture(t, nil) + plugin := writeCLIPlugin(t) + writeCLIMCP(t, plugin) + envelope, err := fixture.app.PackageLoader.Load(context.Background(), domain.LoadInput{SnapshotRoot: plugin}) + if err != nil { + t.Fatal(err) + } + loaded := loadedPackage{envelope: envelope, hints: domain.CompatibilityHints{Compatibility: map[string]domain.CatalogCompatibility{ + "chatgpt": { + Package: "projected", Verification: "tested", Authentication: domain.AuthenticationRequirementNotRequired, + AppBinding: &domain.CatalogAppBinding{AppKey: "demo", ID: "asdk_app_demo_123", MCPServer: "demo", MCPURL: "https://example.test/mcp", RuntimeEvidence: "tests/e2e/results/chatgpt-demo.json", RuntimeEvidenceRevision: strings.Repeat("e", 40)}, + }, + }}} + if err := prepareLoadedPackageForClient(&loaded, domain.ClientChatGPT); err != nil { + t.Fatal(err) + } + if !loaded.envelope.App.Enabled || loaded.envelope.App.Bindings["demo"].ID != "asdk_app_demo_123" || !strings.Contains(string(loaded.envelope.App.Raw), "asdk_app_demo_123") { + t.Fatalf("catalog app synthesis = %+v", loaded.envelope.App) + } + + mismatch := loaded + mismatch.envelope = envelope + binding := *mismatch.hints.Compatibility["chatgpt"].AppBinding + binding.MCPURL = "https://other.example.test/mcp" + compatibility := mismatch.hints.Compatibility["chatgpt"] + compatibility.AppBinding = &binding + mismatch.hints.Compatibility = map[string]domain.CatalogCompatibility{"chatgpt": compatibility} + if err := prepareLoadedPackageForClient(&mismatch, domain.ClientChatGPT); err == nil || !strings.Contains(err.Error(), "does not match") { + t.Fatalf("catalog URL mismatch = %v", err) + } + + existingMismatch := loaded + existingMismatch.envelope = envelope + existingMismatch.envelope.App = domain.AppComponent{ + Present: true, Declared: true, Enabled: true, + Bindings: map[string]domain.AppBinding{"demo": {Alias: "demo", ID: "connector_wrong"}}, + } + if err := prepareLoadedPackageForClient(&existingMismatch, domain.ClientChatGPT); err == nil || !strings.Contains(err.Error(), "does not exactly match") { + t.Fatalf("existing app mismatch = %v", err) + } + + restored := loadedPackage{envelope: envelope} + revisionBinding := domain.ClientBinding{PackageRevision: &domain.ClientPackageRevision{CatalogEvidence: &domain.CatalogEvidence{ + SchemaVersion: 2, Compatibility: cloneCatalogCompatibility(loaded.hints.Compatibility), + }}} + restoreCatalogEvidence(&restored, revisionBinding) + if err := prepareLoadedPackageForClient(&restored, domain.ClientChatGPT); err != nil || !restored.envelope.App.Enabled { + t.Fatalf("persisted catalog evidence did not restore ChatGPT repair binding: %+v, %v", restored.envelope.App, err) + } +} + +func TestRepairRestoresCatalogEvidenceFromSelectedClientRevision(t *testing.T) { + t.Parallel() + fixture := newCLIFixture(t, nil) + plugin := writeCLIPlugin(t) + writeCLIMCP(t, plugin) + envelope, err := fixture.app.PackageLoader.Load(context.Background(), domain.LoadInput{SnapshotRoot: plugin}) + if err != nil { + t.Fatal(err) + } + compatibility := func(id string) map[string]domain.CatalogCompatibility { + return map[string]domain.CatalogCompatibility{"chatgpt": { + Package: "projected", Verification: "tested", Authentication: domain.AuthenticationRequirementNotRequired, + AppBinding: &domain.CatalogAppBinding{AppKey: "demo", ID: id, MCPServer: "demo", MCPURL: "https://example.test/mcp", RuntimeEvidence: "tests/e2e/results/chatgpt-demo.json", RuntimeEvidenceRevision: strings.Repeat("e", 40)}, + }} + } + installation := domain.Installation{Clients: map[string]domain.ClientBinding{ + "chatgpt-old": {ClientID: "chatgpt", PackageRevision: &domain.ClientPackageRevision{ + Version: "1.0.0", TreeDigest: "sha256:tree-a", ManifestDigest: "sha256:manifest-a", + CatalogEvidence: &domain.CatalogEvidence{SchemaVersion: 2, Digest: "sha256:catalog-a", Compatibility: compatibility("connector_app_a")}, + }}, + "codex-new": {ClientID: "codex", PackageRevision: &domain.ClientPackageRevision{ + Version: "2.0.0", TreeDigest: "sha256:tree-b", ManifestDigest: "sha256:manifest-b", + CatalogEvidence: &domain.CatalogEvidence{SchemaVersion: 2, Digest: "sha256:catalog-b", Compatibility: compatibility("connector_app_b")}, + }}, + }} + restored := loadedPackage{envelope: envelope} + restoreCatalogEvidence(&restored, installation.Clients["chatgpt-old"]) + if err := prepareLoadedPackageForClient(&restored, domain.ClientChatGPT); err != nil { + t.Fatal(err) + } + if got := restored.envelope.App.Bindings["demo"].ID; got != "connector_app_a" { + t.Fatalf("ChatGPT repair used evidence from a different client revision: got %q", got) + } +} + +func TestChatGPTAppPackagePreparesManualInstallWithoutDesktopDetection(t *testing.T) { + t.Parallel() + fixture := newCLIFixture(t, []domain.DetectedClient{fixtureClient(t, domain.ClientCodex)}) + plugin := writeCLIPlugin(t) + writeCLIMCP(t, plugin) + app := `{"apps":{"demo":{"id":"asdk_app_demo_123","required":true}}}` + if err := os.WriteFile(filepath.Join(plugin, ".app.json"), []byte(app), 0o644); err != nil { + t.Fatal(err) + } + stdout, _, err := fixture.execute(false, "add", plugin, "--target", "chatgpt") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout, "Package prepared") || !strings.Contains(stdout, "ChatGPT Plugins") || !strings.Contains(stdout, ".app.json") { + t.Fatalf("ChatGPT output = %q", stdout) + } + state, err := fixture.store.Load() + if err != nil || len(state.Installations) != 1 { + t.Fatalf("state = %+v, %v", state, err) + } + binding := onlyCLIClient(state.Installations[0]) + if binding.ClientID != string(domain.ClientChatGPT) || binding.Activation != domain.ActivationManual { + t.Fatalf("ChatGPT binding = %+v", binding) + } + manifest := readCLIObject(t, filepath.Join(binding.TargetLocator, ".codex-plugin", "plugin.json")) + if manifest["apps"] != "./.app.json" || manifest["mcpServers"] != "./.mcp.json" { + t.Fatalf("ChatGPT projection = %+v", manifest) + } + if mcp := readCLIObject(t, filepath.Join(binding.TargetLocator, ".mcp.json")); mcp["mcpServers"] == nil { + t.Fatalf("ChatGPT projection lost bundled MCP parity: %+v", mcp) + } + if _, err := os.Stat(filepath.Join(binding.TargetLocator, "plugin.json")); !os.IsNotExist(err) { + t.Fatalf("portable manifest shadows official ChatGPT projection: %v", err) + } + reloaded, err := fixture.app.PackageLoader.Load(context.Background(), domain.LoadInput{SnapshotRoot: binding.TargetLocator}) + if err != nil { + t.Fatal(err) + } + if reloaded.FormatID != domain.FormatIDOpenAIPlugin || !reloaded.App.Enabled || !reloaded.MCP.Enabled { + t.Fatalf("ChatGPT staged artifact is not a runnable official package: %+v", reloaded) + } + doctor, _, err := fixture.execute(false, "doctor", "demo", "--format", "json") + if err != nil { + t.Fatal(err) + } + if strings.Contains(doctor, "client_not_visible") || !strings.Contains(doctor, "chatgpt_registration_unverified") { + t.Fatalf("ChatGPT doctor findings = %s", doctor) + } +} + +func TestOfficialOpenAIPackagePreparesForChatGPT(t *testing.T) { + t.Parallel() + fixture := newCLIFixture(t, []domain.DetectedClient{{ClientID: domain.ClientChatGPT, DisplayName: "ChatGPT", Status: domain.DetectionNotDetected}}) + plugin := t.TempDir() + if err := os.MkdirAll(filepath.Join(plugin, ".codex-plugin"), 0o755); err != nil { + t.Fatal(err) + } + manifest := `{"name":"official-demo","apps":"./.app.json","mcpServers":"./.mcp.json","interface":{"displayName":"Official Demo"},"future":{"preserved":true}}` + if err := os.WriteFile(filepath.Join(plugin, ".codex-plugin", "plugin.json"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(plugin, ".app.json"), []byte(`{"apps":{"demo":{"id":"plugin_asdk_app_demo_123"}}}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(plugin, ".mcp.json"), []byte(`{"mcpServers":{"demo":{"type":"http","url":"https://example.test/mcp"}}}`), 0o644); err != nil { + t.Fatal(err) + } + if _, _, err := fixture.execute(false, "add", plugin, "--target", "chatgpt"); err != nil { + t.Fatal(err) + } + state, err := fixture.store.Load() + if err != nil || len(state.Installations) != 1 { + t.Fatalf("state = %+v, %v", state, err) + } + installation := state.Installations[0] + if installation.Package.FormatID != domain.FormatIDOpenAIPlugin || installation.Package.SchemaURI != "" { + t.Fatalf("official binding = %+v", installation.Package) + } + projected := readCLIObject(t, filepath.Join(onlyCLIClient(installation).TargetLocator, ".codex-plugin", "plugin.json")) + if projected["apps"] != "./.app.json" || projected["mcpServers"] != "./.mcp.json" || projected["interface"] == nil || projected["future"] == nil { + t.Fatalf("official projection lost fields = %+v", projected) + } + if mcp := readCLIObject(t, filepath.Join(onlyCLIClient(installation).TargetLocator, ".mcp.json")); mcp["mcpServers"] == nil { + t.Fatalf("official ChatGPT projection lost bundled MCP parity: %+v", mcp) + } +} + +func TestOfficialHooksFailClosedBeforeDryRunOrMutation(t *testing.T) { + t.Parallel() + fixture := newCLIFixture(t, []domain.DetectedClient{{ClientID: domain.ClientChatGPT, DisplayName: "ChatGPT", Status: domain.DetectionNotDetected}}) + plugin := t.TempDir() + if err := os.MkdirAll(filepath.Join(plugin, ".codex-plugin"), 0o755); err != nil { + t.Fatal(err) + } + manifest := `{"name":"hooked","hooks":{"PreToolUse":[{"command":"./hooks/run"}]}}` + if err := os.WriteFile(filepath.Join(plugin, ".codex-plugin", "plugin.json"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + if _, _, err := fixture.execute(false, "add", plugin, "--target", "chatgpt", "--dry-run", "--format", "json"); err == nil { + t.Fatal("hook dry-run succeeded") + } else { + var loadErr *domain.LoadError + if !errors.As(err, &loadErr) || loadErr.Diagnostic.Code != "official_hooks_unsupported" { + t.Fatalf("hook dry-run error = %v", err) + } + } + state, err := fixture.store.Load() + if err != nil || len(state.Installations) != 0 { + t.Fatalf("hook dry-run mutated state: %+v, %v", state, err) + } + if _, err := os.Stat(fixture.app.ManagedRoot); !os.IsNotExist(err) { + t.Fatalf("hook dry-run mutated managed filesystem: %v", err) + } +} + +func TestImplicitPortableHooksFailClosedBeforeDryRunOrMutation(t *testing.T) { + t.Parallel() + fixture := newCLIFixture(t, []domain.DetectedClient{{ClientID: domain.ClientChatGPT, DisplayName: "ChatGPT", Status: domain.DetectionNotDetected}}) + plugin := writeCLIPlugin(t) + if err := os.MkdirAll(filepath.Join(plugin, "hooks"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(plugin, "hooks", "hooks.json"), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + if _, _, err := fixture.execute(false, "add", plugin, "--target", "chatgpt", "--dry-run", "--format", "json"); err == nil { + t.Fatal("implicit hook dry-run succeeded") + } else { + var loadErr *domain.LoadError + if !errors.As(err, &loadErr) || loadErr.Diagnostic.Code != "official_hooks_unsupported" || !strings.Contains(err.Error(), "remove the hooks directory") { + t.Fatalf("implicit hook dry-run error = %v", err) + } + } + state, err := fixture.store.Load() + if err != nil || len(state.Installations) != 0 { + t.Fatalf("implicit hook dry-run mutated state: %+v, %v", state, err) + } + if _, err := os.Stat(fixture.app.ManagedRoot); !os.IsNotExist(err) { + t.Fatalf("implicit hook dry-run mutated managed filesystem: %v", err) + } +} + +func TestOfficialOpenAIPackageKeepsBundledMCPAndDropsAppForCodex(t *testing.T) { + t.Parallel() + fixture := newCLIFixture(t, []domain.DetectedClient{fixtureClient(t, domain.ClientCodex)}) + plugin := t.TempDir() + if err := os.MkdirAll(filepath.Join(plugin, ".codex-plugin"), 0o755); err != nil { + t.Fatal(err) + } + manifest := `{"name":"official-codex","mcpServers":"./.mcp.json","apps":"./.app.json","interface":{"displayName":"Official Codex"}}` + if err := os.WriteFile(filepath.Join(plugin, ".codex-plugin", "plugin.json"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(plugin, ".mcp.json"), []byte(`{"mcpServers":{"docs":{"url":"https://example.test/mcp"}}}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(plugin, ".app.json"), []byte(`{"apps":{"docs":{"id":"plugin_asdk_app_docs_123"}}}`), 0o644); err != nil { + t.Fatal(err) + } + if _, _, err := fixture.execute(false, "add", plugin, "--target", "codex"); err != nil { + t.Fatal(err) + } + state, err := fixture.store.Load() + if err != nil || len(state.Installations) != 1 { + t.Fatalf("state = %+v, %v", state, err) + } + target := onlyCLIClient(state.Installations[0]).TargetLocator + projected := readCLIObject(t, filepath.Join(target, ".codex-plugin", "plugin.json")) + if projected["mcpServers"] != "./.mcp.json" || projected["apps"] != nil || projected["interface"] == nil { + t.Fatalf("Codex projection = %+v", projected) + } + mcp := readCLIObject(t, filepath.Join(target, ".mcp.json")) + if mcp["mcpServers"] == nil { + t.Fatalf("Codex MCP projection = %+v", mcp) + } + if _, err := os.Stat(filepath.Join(target, ".app.json")); !os.IsNotExist(err) { + t.Fatalf("Codex projection retained ChatGPT app binding: %v", err) + } +} + +func TestChatGPTBoundLifecycleWorksWithoutLocalDesktopDetection(t *testing.T) { + t.Parallel() + fixture := newCLIFixture(t, []domain.DetectedClient{{ClientID: domain.ClientChatGPT, DisplayName: "ChatGPT", Status: domain.DetectionNotDetected}}) + plugin := writeCLIPlugin(t) + if err := os.WriteFile(filepath.Join(plugin, ".app.json"), []byte(`{"apps":{"demo":{"id":"plugin_asdk_app_demo_123"}}}`), 0o644); err != nil { + t.Fatal(err) + } + if _, _, err := fixture.execute(false, "add", plugin, "--target", "chatgpt"); err != nil { + t.Fatal(err) + } + manifestPath := filepath.Join(plugin, "plugin.json") + body, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(manifestPath, []byte(strings.Replace(string(body), `"version": "1.0.0"`, `"version": "2.0.0"`, 1)), 0o644); err != nil { + t.Fatal(err) + } + if _, _, err := fixture.execute(false, "update", "demo", "--target", "chatgpt"); err != nil { + t.Fatalf("undetected ChatGPT update: %v", err) + } + updated, err := fixture.store.Load() + if err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(onlyCLIClient(updated.Installations[0]).TargetLocator); err != nil { + t.Fatal(err) + } + if _, _, err := fixture.execute(false, "repair", "demo", "--target", "chatgpt"); err != nil { + t.Fatalf("undetected ChatGPT repair: %v", err) + } + if _, _, err := fixture.execute(false, "remove", "demo", "--target", "chatgpt", "--external-uninstalled"); err != nil { + t.Fatalf("undetected ChatGPT remove: %v", err) + } + state, err := fixture.store.Load() + if err != nil { + t.Fatal(err) + } + binding := onlyCLIClient(state.Installations[0]) + if state.Installations[0].Package.Version != "2.0.0" || binding.Materialization != domain.MaterializationAbsent { + t.Fatalf("ChatGPT lifecycle state = %+v", state.Installations[0]) + } +} + +func TestCodexAndChatGPTUseIndependentBindings(t *testing.T) { + t.Parallel() + fixture := newCLIFixture(t, []domain.DetectedClient{ + fixtureClient(t, domain.ClientCodex), + {ClientID: domain.ClientChatGPT, DisplayName: "ChatGPT", Status: domain.DetectionNotDetected}, + }) + plugin := writeCLIPlugin(t) + writeCLIMCP(t, plugin) + if err := os.WriteFile(filepath.Join(plugin, ".app.json"), []byte(`{"apps":{"demo":{"id":"asdk_app_demo_123"}}}`), 0o644); err != nil { + t.Fatal(err) + } + for _, target := range []string{"codex", "chatgpt"} { + if _, _, err := fixture.execute(false, "add", plugin, "--target", target); err != nil { + t.Fatalf("add %s: %v", target, err) + } + } + state, err := fixture.store.Load() + if err != nil || len(state.Installations) != 1 || len(state.Installations[0].Clients) != 2 { + t.Fatalf("state = %+v, %v", state, err) + } + seen := map[string]domain.ClientBinding{} + for _, binding := range state.Installations[0].Clients { + seen[binding.ClientID] = binding + } + if seen["codex"].ClientBindingID == seen["chatgpt"].ClientBindingID || seen["codex"].TargetLocator == seen["chatgpt"].TargetLocator { + t.Fatalf("bindings were conflated: %+v", seen) } } @@ -1000,6 +1532,49 @@ func TestMigrateStateIsExplicitPlanFirstAndPathRedacted(t *testing.T) { } } +func TestReadOnlyCommandsLoadLegacyV2WithoutPersistingMigration(t *testing.T) { + t.Parallel() + fixture := newCLIFixture(t, nil) + installationID := "00000000-0000-4000-8000-000000000001" + target := filepath.Join(fixture.root, "managed", "demo") + clientBindingID := domain.ComputeClientBindingID(installationID, "cursor", "user", target) + legacy := domain.StateFileV2{SchemaVersion: domain.LegacyStateSchemaVersion, Installations: []domain.Installation{{ + InstallationID: installationID, DeclaredName: "demo", + Source: domain.SourceBinding{SourceBindingID: "src_demo", RequestedSource: "demo", CanonicalSource: "https://example.test/demo", ResolvedRevision: "abc123", TreeDigest: "sha256:tree"}, + Package: domain.PackageBinding{LoaderKind: domain.LoaderKindAgentPlugins, FormatID: domain.FormatIDAgentPluginsV1, SchemaURI: domain.PluginSchemaV1, DeclaredName: "demo", ManifestDigest: "sha256:manifest"}, + Clients: map[string]domain.ClientBinding{clientBindingID: { + ClientBindingID: clientBindingID, ClientID: "cursor", Scope: "user", TargetLocator: target, + PhysicalArtifact: domain.ComputePhysicalArtifactID("demo", installationID), Materialization: domain.MaterializationMaterialized, + Activation: domain.ActivationManual, Authentication: domain.AuthenticationNotRequired, + Policy: domain.PolicyAllowed, Verification: domain.VerificationPackageValid, + }}, + }}} + body, err := json.MarshalIndent(legacy, "", " ") + if err != nil { + t.Fatal(err) + } + body = append(body, '\n') + if err := os.MkdirAll(filepath.Dir(fixture.store.Path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fixture.store.Path, body, 0o600); err != nil { + t.Fatal(err) + } + if _, _, err := fixture.execute(false, "list", "--format", "json"); err != nil { + t.Fatal(err) + } + if _, _, err := fixture.execute(false, "doctor", "demo", "--format", "json"); err != nil { + t.Fatal(err) + } + after, err := os.ReadFile(fixture.store.Path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(after, body) { + t.Fatalf("read-only commands persisted the v2-to-v3 migration:\n%s", after) + } +} + func TestLegacyRemovalRequiresExplicitAllTargetAndReconcilesV2(t *testing.T) { t.Parallel() fixture := newCLIFixture(t, nil) @@ -1190,6 +1765,35 @@ func writeCLIPlugin(t *testing.T) string { return root } +func writeCLIMCP(t *testing.T, root string) { + t.Helper() + body := `{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"demo":{"type":"streamable-http","url":"https://example.test/mcp"}}}` + if err := os.WriteFile(filepath.Join(root, "mcp.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func writeCLIApp(t *testing.T, root string) { + t.Helper() + body := `{"apps":{"demo":{"id":"asdk_app_demo_123","required":true}}}` + if err := os.WriteFile(filepath.Join(root, ".app.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func readCLIObject(t *testing.T, path string) map[string]any { + t.Helper() + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var value map[string]any + if err := json.Unmarshal(body, &value); err != nil { + t.Fatal(err) + } + return value +} + func assertVersionedJSON(t *testing.T, body, command string) { t.Helper() var value map[string]any diff --git a/cli/plugin-kit-ai/internal/agentpluginscli/lifecycle.go b/cli/plugin-kit-ai/internal/agentpluginscli/lifecycle.go index d791f6d3..817ef349 100644 --- a/cli/plugin-kit-ai/internal/agentpluginscli/lifecycle.go +++ b/cli/plugin-kit-ai/internal/agentpluginscli/lifecycle.go @@ -81,6 +81,10 @@ func runRepair(ctx context.Context, cmd *cobra.Command, app App, opts *options, if loaded.cleanup != nil { defer loaded.cleanup() } + restoreCatalogEvidence(&loaded, binding) + if err := prepareLoadedPackageForClient(&loaded, selected.ClientID); err != nil { + return err + } if err := requireNonInteractiveMutation(app, opts, "repair"); err != nil && !opts.dryRun { return err } @@ -90,6 +94,15 @@ func runRepair(ctx context.Context, cmd *cobra.Command, app App, opts *options, BackendExecutable: backendExecutable(selected, detectedMap)} planned, err := service.Repair(ctx, input) if err != nil { + if planned.Plan.Status == domain.PlanUnsupported { + if opts.format == "json" { + if renderErr := renderRepairResult(cmd.OutOrStdout(), opts.format, installation, planned, opts.dryRun); renderErr != nil { + return renderErr + } + } else if renderErr := renderHumanPlan(cmd.OutOrStdout(), loaded.envelope, planned); renderErr != nil { + return renderErr + } + } return err } if opts.dryRun || planned.NoChange { @@ -180,6 +193,9 @@ func runUpdate(ctx context.Context, cmd *cobra.Command, app App, opts *options, if err != nil { return err } + if err := prepareLoadedPackageForClient(&loaded, selected.ClientID); err != nil { + return err + } if err := requireNonInteractiveMutation(app, opts, "update"); err != nil && !opts.dryRun { return err } @@ -192,6 +208,15 @@ func runUpdate(ctx context.Context, cmd *cobra.Command, app App, opts *options, } planned, err := service.Update(ctx, input) if err != nil { + if planned.Plan.Status == domain.PlanUnsupported { + if opts.format == "json" { + if renderErr := renderUpdateResult(cmd.OutOrStdout(), opts.format, loaded.envelope, planned, opts.dryRun); renderErr != nil { + return renderErr + } + } else if renderErr := renderHumanPlan(cmd.OutOrStdout(), loaded.envelope, planned); renderErr != nil { + return renderErr + } + } return err } if opts.dryRun || planned.NoChange { @@ -374,7 +399,7 @@ func renderLegacyRemove(writer io.Writer, format string, result usecase.LegacyRe return nil } if result.Mutated { - _, _ = fmt.Fprintln(writer, "Legacy targets removed and State v2 reconciled.") + _, _ = fmt.Fprintln(writer, "Legacy targets removed and Agent Plugins state reconciled.") } return nil } @@ -386,7 +411,7 @@ func renderLegacyRemovePlan(writer io.Writer, result usecase.LegacyRemoveResult) _, _ = fmt.Fprintf(writer, " - %s\n", target) } if result.Reconciled { - _, _ = fmt.Fprintln(writer, "Legacy lifecycle already reports the installation absent; only State v2 reconciliation remains.") + _, _ = fmt.Fprintln(writer, "Legacy lifecycle already reports the installation absent; only Agent Plugins state reconciliation remains.") } } @@ -504,11 +529,14 @@ func selectBoundClient( if !ok { client = domain.DetectedClient{ClientID: clientID, DisplayName: string(clientID), Status: domain.DetectionNotDetected} } - if requireDetected && client.Status != domain.DetectionDetected { + if requireDetected && client.Status != domain.DetectionDetected && clientID != domain.ClientChatGPT { return domain.DetectedClient{}, fmt.Errorf("target %q is no longer detected; remove remains available", clientID) } return client, nil } + if strings.EqualFold(strings.TrimSpace(opts.target), "openai") { + return domain.DetectedClient{}, detectedMap, fmt.Errorf("target %q is ambiguous; use --target codex or --target chatgpt", opts.target) + } if target := normalizeTarget(opts.target); target != "" { client, err := choose(target) return client, detectedMap, err diff --git a/cli/plugin-kit-ai/internal/agentpluginscli/read.go b/cli/plugin-kit-ai/internal/agentpluginscli/read.go index a898b38b..7c497adc 100644 --- a/cli/plugin-kit-ai/internal/agentpluginscli/read.go +++ b/cli/plugin-kit-ai/internal/agentpluginscli/read.go @@ -199,7 +199,7 @@ func normalizedToolVersion(version string) string { } func doctorSupportedClients() []supportedClient { - ids := []domain.ClientID{domain.ClientCodex, domain.ClientCursor, domain.ClientCopilot, domain.ClientVSCode, domain.ClientKiro} + ids := []domain.ClientID{domain.ClientCodex, domain.ClientChatGPT, domain.ClientCursor, domain.ClientCopilot, domain.ClientVSCode, domain.ClientKiro} result := make([]supportedClient, 0, len(ids)) for _, id := range ids { capabilities, _ := clientplanner.Capabilities(id) @@ -234,9 +234,22 @@ func doctorFindings(ctx context.Context, app App, detected []domain.DetectedClie continue } client, visible := detectedByID[binding.ClientID] - if !visible || client.Status != domain.DetectionDetected { + if binding.ClientID == string(domain.ClientChatGPT) && !visible { + client = domain.DetectedClient{ClientID: domain.ClientChatGPT, DisplayName: "ChatGPT", Status: domain.DetectionNotDetected} + } + if binding.ClientID != string(domain.ClientChatGPT) && (!visible || client.Status != domain.DetectionDetected) { findings = append(findings, scopedFinding("degraded", "client_not_visible", installation, binding.ClientID, "the package is tracked but no current client visibility evidence was detected", "install or launch the client so its CLI, desktop application, or configuration directory is visible, then rerun doctor")) } + if binding.ClientID == string(domain.ClientChatGPT) { + inventoryCurrent := packageInventoryAppliesToBinding(installation, binding) + if inventoryCurrent && installation.Package.Inventory.MCPPresent && (!installation.Package.Inventory.AppPresent || len(installation.Package.Inventory.AppBindings) == 0) { + findings = append(findings, scopedFinding("degraded", "chatgpt_app_binding_missing", installation, binding.ClientID, "the ChatGPT target has MCP content but no valid registered app mapping", "register the MCP connection in ChatGPT Developer Mode, add a valid root .app.json mapping, then update this target")) + } else if !inventoryCurrent { + findings = append(findings, scopedFinding("unknown", "chatgpt_registration_unverified", installation, binding.ClientID, "remote ChatGPT registration for this earlier client revision cannot be inferred from the latest package inventory", "verify the installed plugin revision and connection status in ChatGPT Plugins; update this target before relying on current package metadata")) + } else if installation.Package.Inventory.AppPresent { + findings = append(findings, scopedFinding("unknown", "chatgpt_registration_unverified", installation, binding.ClientID, "the .app.json mapping is package-valid, but remote ChatGPT registration cannot be observed locally", "verify the mapped connection and plugin status in ChatGPT Plugins; rerun add with --activation-complete only after checking it in a new chat")) + } + } if visible && client.Status == domain.DetectionDetected && binding.ClientID == string(domain.ClientCopilot) && strings.TrimSpace(client.ExecutablePath) == "" { findings = append(findings, scopedFinding("degraded", "copilot_cli_missing", installation, binding.ClientID, "GitHub Copilot CLI is unavailable for automatic Copilot activation", "install GitHub Copilot CLI, ensure copilot is on PATH, and rerun doctor")) } @@ -257,7 +270,7 @@ func doctorFindings(ctx context.Context, app App, detected []domain.DetectedClie if binding.Materialization == domain.MaterializationDegraded || binding.Verification == domain.VerificationFailed { findings = append(findings, scopedFinding("degraded", "installation_verification_failed", installation, binding.ClientID, "the managed package is marked degraded or failed verification", repairAction(installation, binding))) } - if visible && client.Status == domain.DetectionDetected { + if binding.ClientID == string(domain.ClientChatGPT) || (visible && client.Status == domain.DetectionDetected) { findings = append(findings, checkManagedIntegrity(ctx, app, client, installation, binding)...) } } @@ -268,6 +281,14 @@ func doctorFindings(ctx context.Context, app App, detected []domain.DetectedClie return findings } +func packageInventoryAppliesToBinding(installation domain.Installation, binding domain.ClientBinding) bool { + if binding.PackageRevision == nil { + return true + } + return binding.PackageRevision.ManifestDigest == installation.Package.ManifestDigest && + binding.PackageRevision.TreeDigest == installation.Source.TreeDigest +} + const blockedStateRecovery = "automatic mutation is intentionally blocked; restore state-v2.json from a trusted backup that matches the managed source, or recover and review the original source and binding metadata" func checkManagedIntegrity(ctx context.Context, app App, client domain.DetectedClient, installation domain.Installation, binding domain.ClientBinding) []doctorFinding { diff --git a/cli/plugin-kit-ai/internal/agentpluginscli/root.go b/cli/plugin-kit-ai/internal/agentpluginscli/root.go index c424c83b..74ef44f0 100644 --- a/cli/plugin-kit-ai/internal/agentpluginscli/root.go +++ b/cli/plugin-kit-ai/internal/agentpluginscli/root.go @@ -19,7 +19,7 @@ func NewRoot(app App) *cobra.Command { root.SetOut(app.output()) root.SetErr(app.errorOutput()) flags := root.PersistentFlags() - flags.StringVar(&opts.target, "target", "", "target client: codex, cursor, copilot, vscode, or kiro") + flags.StringVar(&opts.target, "target", "", "target client: codex, chatgpt, cursor, copilot, vscode, or kiro") flags.StringVar(&opts.scope, "scope", "user", "installation scope: user or project") flags.BoolVar(&opts.dryRun, "dry-run", false, "show the exact plan without changes") flags.BoolVar(&opts.yes, "yes", false, "confirm this selected target without installing everywhere") diff --git a/cli/plugin-kit-ai/internal/agentpluginscli/source.go b/cli/plugin-kit-ai/internal/agentpluginscli/source.go index b86374d5..1870da4c 100644 --- a/cli/plugin-kit-ai/internal/agentpluginscli/source.go +++ b/cli/plugin-kit-ai/internal/agentpluginscli/source.go @@ -2,6 +2,7 @@ package agentpluginscli import ( "context" + "encoding/json" "fmt" "io" "net/http" @@ -102,11 +103,77 @@ func cloneCatalogCompatibility(source map[string]domain.CatalogCompatibility) ma } result := make(map[string]domain.CatalogCompatibility, len(source)) for client, compatibility := range source { + if compatibility.AppBinding != nil { + binding := *compatibility.AppBinding + compatibility.AppBinding = &binding + } result[client] = compatibility } return result } +func prepareLoadedPackageForClient(loaded *loadedPackage, clientID domain.ClientID) error { + if loaded == nil || clientID != domain.ClientChatGPT { + return nil + } + compatibility, ok := loaded.hints.Compatibility[string(domain.ClientChatGPT)] + if !ok || compatibility.AppBinding == nil { + return nil + } + binding := *compatibility.AppBinding + if err := catalog.ValidateAppBinding(binding); err != nil { + return fmt.Errorf("catalog ChatGPT app binding is invalid: %w", err) + } + server, ok := loaded.envelope.MCP.Servers[binding.MCPServer] + if !ok || !loaded.envelope.MCP.Enabled { + return fmt.Errorf("catalog ChatGPT app binding references missing MCP server %q", binding.MCPServer) + } + serverURL, ok := server.Decoded["url"].(string) + if !ok || serverURL != binding.MCPURL { + return fmt.Errorf("catalog ChatGPT app binding URL does not match MCP server %q", binding.MCPServer) + } + if loaded.envelope.App.Present { + existing, matches := loaded.envelope.App.Bindings[binding.AppKey] + if !loaded.envelope.App.Enabled || !matches || len(loaded.envelope.App.Bindings) != 1 || existing.ID != binding.ID { + return fmt.Errorf("package .app.json does not exactly match the catalog ChatGPT app binding") + } + return nil + } + entry := struct { + ID string `json:"id"` + }{ID: binding.ID} + document := struct { + Apps map[string]any `json:"apps"` + }{Apps: map[string]any{binding.AppKey: entry}} + raw, err := json.Marshal(document) + if err != nil { + return fmt.Errorf("encode catalog ChatGPT app binding: %w", err) + } + entryRaw, err := json.Marshal(entry) + if err != nil { + return fmt.Errorf("encode catalog ChatGPT app entry: %w", err) + } + loaded.envelope.App = domain.AppComponent{ + Present: true, Declared: true, Enabled: true, Raw: raw, + Bindings: map[string]domain.AppBinding{ + binding.AppKey: {Alias: binding.AppKey, ID: binding.ID, Raw: entryRaw}, + }, + } + loaded.envelope.Inventory.AppPresent = true + loaded.envelope.Inventory.AppBindings = []string{binding.AppKey} + return nil +} + +func restoreCatalogEvidence(loaded *loadedPackage, binding domain.ClientBinding) { + if loaded == nil || loaded.envelope.CatalogEvidence != nil || binding.PackageRevision == nil || binding.PackageRevision.CatalogEvidence == nil { + return + } + evidence := *binding.PackageRevision.CatalogEvidence + evidence.Compatibility = cloneCatalogCompatibility(evidence.Compatibility) + loaded.envelope.CatalogEvidence = &evidence + loaded.hints.Compatibility = cloneCatalogCompatibility(evidence.Compatibility) +} + func (app App) resolveCatalogName(ctx context.Context, name string) (domain.CatalogResolution, error) { body := append([]byte(nil), app.CatalogBody...) if len(body) == 0 { diff --git a/cli/plugin-kit-ai/internal/agentpluginscli/state_migration.go b/cli/plugin-kit-ai/internal/agentpluginscli/state_migration.go index c0138a17..d6dca7ba 100644 --- a/cli/plugin-kit-ai/internal/agentpluginscli/state_migration.go +++ b/cli/plugin-kit-ai/internal/agentpluginscli/state_migration.go @@ -13,7 +13,7 @@ import ( func newMigrateStateCommand(app App, opts *options) *cobra.Command { return &cobra.Command{ Use: "migrate-state", - Short: "Explicitly migrate legacy plugin-kit-ai state into State v2", + Short: "Explicitly migrate legacy plugin-kit-ai state into Agent Plugins state", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { if err := validateCommonOptions(opts); err != nil { @@ -97,7 +97,7 @@ func renderStateMigration(writer io.Writer, format string, plan statemigration.P renderStateMigrationPlan(writer, plan) return nil } - _, _ = fmt.Fprintf(writer, "Migrated %d legacy installation(s); backup created before State v2 commit.\n", report.Migrated) + _, _ = fmt.Fprintf(writer, "Migrated %d legacy installation(s); backup created before Agent Plugins state commit.\n", report.Migrated) if report.NeedsRebind > 0 { _, _ = fmt.Fprintf(writer, "%d installation(s) require explicit rebind before update.\n", report.NeedsRebind) } @@ -108,5 +108,5 @@ func renderStateMigration(writer io.Writer, format string, plan statemigration.P func renderStateMigrationPlan(writer io.Writer, plan statemigration.Plan) { _, _ = fmt.Fprintf(writer, "Legacy installations: %d\n", plan.Installations) _, _ = fmt.Fprintf(writer, "Require rebind: %d\n", plan.NeedsRebind) - _, _ = fmt.Fprintln(writer, "The legacy state remains unchanged and is backed up before State v2 is committed.") + _, _ = fmt.Fprintln(writer, "The legacy state remains unchanged and is backed up before Agent Plugins state is committed.") } diff --git a/install/integrationctl/agentplugins/adapters/catalog/catalog.go b/install/integrationctl/agentplugins/adapters/catalog/catalog.go index 6a1fd3b6..2f24d62f 100644 --- a/install/integrationctl/agentplugins/adapters/catalog/catalog.go +++ b/install/integrationctl/agentplugins/adapters/catalog/catalog.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "net/url" "path" "regexp" "sort" @@ -18,13 +19,19 @@ import ( "golang.org/x/mod/semver" ) -const SchemaVersion = 1 +const ( + SchemaVersionV1 = 1 + SchemaVersionV2 = 2 + minimumV2CLI = "v0.1.6" +) var ( repositoryPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`) commitPattern = regexp.MustCompile(`^[0-9a-f]{40}$`) digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) namePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9.-]{0,63}$`) + appAliasPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + appIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._~:-]{0,255}$`) ) var requiredCompatibility = map[string]string{ @@ -55,7 +62,7 @@ func (loader Loader) Load(body []byte, expectedDigest string) (Loaded, error) { decoder.DisallowUnknownFields() var value domain.CatalogV1 if err := decoder.Decode(&value); err != nil { - return Loaded{}, fmt.Errorf("decode catalog v1: %w", err) + return Loaded{}, fmt.Errorf("decode catalog: %w", err) } if err := decoder.Decode(&struct{}{}); err != io.EOF { return Loaded{}, fmt.Errorf("catalog contains trailing JSON values") @@ -63,11 +70,15 @@ func (loader Loader) Load(body []byte, expectedDigest string) (Loaded, error) { if err := validateCatalog(value); err != nil { return Loaded{}, err } + cliVersion := normalizeVersion(loader.CurrentCLIVersion) + if value.SchemaVersion == SchemaVersionV2 && cliVersion != "" && semver.Compare(cliVersion, minimumV2CLI) < 0 { + return Loaded{}, fmt.Errorf("catalog v2 requires agentplugins 0.1.6 or newer") + } byName := make(map[string]domain.CatalogPlugin, len(value.Plugins)) for _, plugin := range value.Plugins { byName[plugin.Name] = plugin } - return Loaded{Catalog: value, Digest: digest, byName: byName, cli: normalizeVersion(loader.CurrentCLIVersion)}, nil + return Loaded{Catalog: value, Digest: digest, byName: byName, cli: cliVersion}, nil } func (loaded Loaded) Resolve(name string) (domain.CatalogResolution, error) { @@ -101,7 +112,9 @@ func (loaded Loaded) Resolve(name string) (domain.CatalogResolution, error) { } func validateCatalog(value domain.CatalogV1) error { - if value.Schema != domain.CatalogSchemaV1 || value.SchemaVersion != SchemaVersion { + supportedSchema := (value.SchemaVersion == SchemaVersionV1 && value.Schema == domain.CatalogSchemaV1) || + (value.SchemaVersion == SchemaVersionV2 && value.Schema == domain.CatalogSchemaV2) + if !supportedSchema { return fmt.Errorf("unsupported catalog schema") } if !semver.IsValid(normalizeVersion(value.CatalogVersion)) { @@ -118,7 +131,7 @@ func validateCatalog(value domain.CatalogV1) error { } seen := map[string]domain.CatalogPlugin{} for index, plugin := range value.Plugins { - if err := validatePlugin(plugin); err != nil { + if err := validatePlugin(plugin, value.SchemaVersion); err != nil { return fmt.Errorf("plugins[%d]: %w", index, err) } if previous, exists := seen[plugin.Name]; exists { @@ -132,13 +145,16 @@ func validateCatalog(value domain.CatalogV1) error { return nil } -func validatePlugin(plugin domain.CatalogPlugin) error { +func validatePlugin(plugin domain.CatalogPlugin, schemaVersion int) error { if !namePattern.MatchString(plugin.Name) || strings.Contains(plugin.Name, "..") || strings.HasSuffix(plugin.Name, ".") { return fmt.Errorf("invalid plugin name %q", plugin.Name) } if !semver.IsValid(normalizeVersion(plugin.Version)) || !semver.IsValid(normalizeVersion(plugin.MinimumCLIVersion)) { return fmt.Errorf("plugin %q has invalid version metadata", plugin.Name) } + if schemaVersion == SchemaVersionV2 && semver.Compare(normalizeVersion(plugin.MinimumCLIVersion), minimumV2CLI) < 0 { + return fmt.Errorf("plugin %q catalog v2 minimum_cli_version must be 0.1.6 or newer", plugin.Name) + } if plugin.AgentPluginsSchema != domain.PluginSchemaV1 { return fmt.Errorf("plugin %q uses unsupported Agent Plugins schema", plugin.Name) } @@ -158,8 +174,15 @@ func validatePlugin(plugin domain.CatalogPlugin) error { } components[component] = struct{}{} } - if len(plugin.Compatibility) != len(requiredCompatibility) { - return fmt.Errorf("plugin %q compatibility must contain exactly codex, cursor, copilot, vscode, and kiro", plugin.Name) + allowChatGPT := schemaVersion == SchemaVersionV2 + if (!allowChatGPT && len(plugin.Compatibility) != len(requiredCompatibility)) || + (allowChatGPT && (len(plugin.Compatibility) < len(requiredCompatibility) || len(plugin.Compatibility) > len(requiredCompatibility)+1)) { + return fmt.Errorf("plugin %q compatibility has the wrong client set for catalog schema v%d", plugin.Name, schemaVersion) + } + for client := range plugin.Compatibility { + if _, required := requiredCompatibility[client]; !required && (!allowChatGPT || client != string(domain.ClientChatGPT)) { + return fmt.Errorf("plugin %q compatibility contains unsupported client %q", plugin.Name, client) + } } var authentication domain.AuthenticationRequirement for client, expectedPackage := range requiredCompatibility { @@ -171,12 +194,32 @@ func validatePlugin(plugin domain.CatalogPlugin) error { !validVerificationCompatibility(compatibility.Verification) || !validAuthCompatibility(compatibility.Authentication) { return fmt.Errorf("plugin %q has invalid compatibility for %q", plugin.Name, client) } + if compatibility.AppBinding != nil { + return fmt.Errorf("plugin %q app_binding is allowed only for chatgpt", plugin.Name) + } if authentication == "" { authentication = compatibility.Authentication } else if compatibility.Authentication != authentication { return fmt.Errorf("plugin %q must use one consistent authentication requirement for every client", plugin.Name) } } + if compatibility, ok := plugin.Compatibility[string(domain.ClientChatGPT)]; ok { + if compatibility.Package != "projected" || !validVerificationCompatibility(compatibility.Verification) || !validAuthCompatibility(compatibility.Authentication) { + return fmt.Errorf("plugin %q has invalid compatibility for chatgpt", plugin.Name) + } + if authentication != "" && compatibility.Authentication != authentication { + return fmt.Errorf("plugin %q must use one consistent authentication requirement for every client", plugin.Name) + } + _, hasMCP := components["mcp"] + if hasMCP && compatibility.AppBinding == nil { + return fmt.Errorf("plugin %q ChatGPT MCP compatibility requires app_binding", plugin.Name) + } + if compatibility.AppBinding != nil { + if err := ValidateAppBinding(*compatibility.AppBinding); err != nil { + return fmt.Errorf("plugin %q chatgpt app_binding: %w", plugin.Name, err) + } + } + } for server, hint := range plugin.OpenAIMCPAuth { if strings.TrimSpace(server) == "" || (hint.OAuthResource == "" && hint.BearerTokenEnvVar == "") { return fmt.Errorf("plugin %q has invalid OpenAI auth hint", plugin.Name) @@ -185,6 +228,29 @@ func validatePlugin(plugin domain.CatalogPlugin) error { return nil } +func ValidateAppBinding(binding domain.CatalogAppBinding) error { + if !appAliasPattern.MatchString(binding.AppKey) || !appAliasPattern.MatchString(binding.MCPServer) { + return fmt.Errorf("app_key and mcp_server must be safe aliases") + } + if binding.AppKey != binding.MCPServer { + return fmt.Errorf("app_key must equal mcp_server in v0.1") + } + if !appIDPattern.MatchString(binding.ID) { + return fmt.Errorf("id must be a non-empty opaque safe ASCII token") + } + parsed, err := url.Parse(binding.MCPURL) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.String() != binding.MCPURL { + return fmt.Errorf("mcp_url must be a normalized absolute HTTPS URL without userinfo, query, or fragment") + } + if err := validateSourcePath(binding.RuntimeEvidence); err != nil { + return fmt.Errorf("runtime_evidence: %w", err) + } + if !commitPattern.MatchString(binding.RuntimeEvidenceRevision) { + return fmt.Errorf("runtime_evidence_revision must be an exact lowercase Git commit") + } + return nil +} + func validateSourcePath(value string) error { if value == "" || !utf8.ValidString(value) || strings.ContainsAny(value, "\\\x00") || strings.HasPrefix(value, "/") { return fmt.Errorf("path must be a portable relative path") @@ -220,6 +286,10 @@ func cloneCompatibility(source map[string]domain.CatalogCompatibility) map[strin } result := make(map[string]domain.CatalogCompatibility, len(source)) for key, value := range source { + if value.AppBinding != nil { + binding := *value.AppBinding + value.AppBinding = &binding + } result[key] = value } return result diff --git a/install/integrationctl/agentplugins/adapters/catalog/catalog_test.go b/install/integrationctl/agentplugins/adapters/catalog/catalog_test.go index b11d200d..05d357ae 100644 --- a/install/integrationctl/agentplugins/adapters/catalog/catalog_test.go +++ b/install/integrationctl/agentplugins/adapters/catalog/catalog_test.go @@ -82,6 +82,62 @@ func TestCatalogEnforcesMinimumCLIVersionAtResolution(t *testing.T) { } } +func TestCatalogLoadsIntegrityBoundChatGPTAppBinding(t *testing.T) { + t.Parallel() + loaded, err := (Loader{CurrentCLIVersion: "0.1.6"}).Load(validCatalogWithChatGPT(), "") + if err != nil { + t.Fatal(err) + } + resolved, err := loaded.Resolve("context7") + if err != nil { + t.Fatal(err) + } + binding := resolved.Hints.Compatibility["chatgpt"].AppBinding + if binding == nil || binding.AppKey != "context7" || binding.ID != "asdk_app_context7_123" || binding.MCPURL != "https://example.test/mcp" { + t.Fatalf("ChatGPT app binding = %+v", binding) + } + binding.ID = "connector_mutated" + if resolved.Evidence.Compatibility["chatgpt"].AppBinding.ID != "asdk_app_context7_123" { + t.Fatal("mutable hints aliased immutable catalog evidence") + } +} + +func TestCatalogV1RejectsV2ChatGPTFields(t *testing.T) { + t.Parallel() + body := strings.Replace(string(validCatalogWithChatGPT()), domain.CatalogSchemaV2, domain.CatalogSchemaV1, 1) + body = strings.Replace(body, `"schema_version": 2`, `"schema_version": 1`, 1) + if _, err := (Loader{CurrentCLIVersion: "0.1.0"}).Load([]byte(body), ""); err == nil { + t.Fatal("catalog v1 accepted v2 ChatGPT fields") + } +} + +func TestCatalogV2RequiresAgentplugins016(t *testing.T) { + t.Parallel() + if _, err := (Loader{CurrentCLIVersion: "0.1.5"}).Load(validCatalogWithChatGPT(), ""); err == nil || !strings.Contains(err.Error(), "0.1.6") { + t.Fatalf("pre-0.1.6 CLI loaded catalog v2: %v", err) + } +} + +func TestCatalogRejectsUnsafeOrMisplacedChatGPTAppBinding(t *testing.T) { + t.Parallel() + valid := string(validCatalogWithChatGPT()) + for name, body := range map[string]string{ + "url-query": strings.Replace(valid, `"https://example.test/mcp"`, `"https://example.test/mcp?token=x"`, 1), + "bad-id": strings.Replace(valid, `"asdk_app_context7_123"`, `"not/an/app/id"`, 1), + "unsafe-evidence": strings.Replace(valid, + `"tests/e2e/results/chatgpt-context7.json"`, `"../outside.json"`, 1), + "bad-evidence-revision": strings.Replace(valid, strings.Repeat("e", 40), `not-a-commit`, 1), + "non-chatgpt": strings.Replace(valid, `"authentication":"not_required"}`, `"authentication":"not_required","app_binding":{"app_key":"context7","id":"asdk_app_context7_123","mcp_server":"context7","mcp_url":"https://example.test/mcp","runtime_evidence":"tests/e2e/results/chatgpt-context7.json"}}`, 1), + } { + name, body := name, body + t.Run(name, func(t *testing.T) { + if _, err := (Loader{CurrentCLIVersion: "0.1.0"}).Load([]byte(body), ""); err == nil { + t.Fatal("invalid ChatGPT app binding accepted") + } + }) + } +} + func TestCatalogRejectsAnyCompatibilityMatrixDeviation(t *testing.T) { t.Parallel() tests := map[string]string{ @@ -111,19 +167,22 @@ func TestCatalogRejectsAnyCompatibilityMatrixDeviation(t *testing.T) { } } -func TestEmbeddedCatalogAllEntriesUseExactCompatibilityMatrix(t *testing.T) { +func TestEmbeddedCatalogV2LoadsAllEntries(t *testing.T) { t.Parallel() - body, err := os.ReadFile("../../../../../cli/plugin-kit-ai/cmd/agentplugins/catalog-v1.json") + body, err := os.ReadFile("../../../../../cli/plugin-kit-ai/cmd/agentplugins/catalog-v2.json") if err != nil { t.Fatal(err) } - loaded, err := (Loader{CurrentCLIVersion: "0.1.4"}).Load(body, "") + loaded, err := (Loader{CurrentCLIVersion: "0.1.6"}).Load(body, "") if err != nil { t.Fatal(err) } if len(loaded.Catalog.Plugins) != 26 { t.Fatalf("embedded package count = %d, want 26", len(loaded.Catalog.Plugins)) } + if loaded.Catalog.SchemaVersion != SchemaVersionV2 { + t.Fatalf("embedded catalog schema_version = %d, want %d", loaded.Catalog.SchemaVersion, SchemaVersionV2) + } } func validCatalog() []byte { @@ -149,6 +208,15 @@ func validCatalog() []byte { }`) } +func validCatalogWithChatGPT() []byte { + body := strings.Replace(string(validCatalog()), domain.CatalogSchemaV1, domain.CatalogSchemaV2, 1) + body = strings.Replace(body, `"schema_version": 1`, `"schema_version": 2`, 1) + body = strings.Replace(body, `"minimum_cli_version": "0.1.0"`, `"minimum_cli_version": "0.1.6"`, 1) + needle := `"kiro":{"package":"native","verification":"tested","authentication":"not_required"}` + chatgpt := `,"chatgpt":{"package":"projected","verification":"tested","authentication":"not_required","app_binding":{"app_key":"context7","id":"asdk_app_context7_123","mcp_server":"context7","mcp_url":"https://example.test/mcp","runtime_evidence":"tests/e2e/results/chatgpt-context7.json","runtime_evidence_revision":"` + strings.Repeat("e", 40) + `"}}` + return []byte(strings.Replace(body, needle, needle+chatgpt, 1)) +} + func digest(value string) string { return "sha256:" + strings.Repeat(value, 64) } diff --git a/install/integrationctl/agentplugins/adapters/clientdetect/detector.go b/install/integrationctl/agentplugins/adapters/clientdetect/detector.go index 8282ae22..a831f3ea 100644 --- a/install/integrationctl/agentplugins/adapters/clientdetect/detector.go +++ b/install/integrationctl/agentplugins/adapters/clientdetect/detector.go @@ -64,6 +64,7 @@ func (detector Detector) Detect(ctx context.Context) ([]domain.DetectedClient, e } clients := []domain.DetectedClient{ detector.detectCodex(), + detector.detectChatGPT(), detector.detectCursor(), detector.detectCopilot(), detector.detectVSCode(), @@ -80,18 +81,30 @@ func (detector Detector) detectCodex() domain.DetectedClient { detector.directorySurface("codex_config", configRoot), } if detector.GOOS == "darwin" { - surfaces = append(surfaces, - detector.appSurface("codex_desktop", "Codex.app"), - detector.appSurface("chatgpt_desktop", "ChatGPT.app"), - ) - } else if detector.GOOS == "windows" { - surfaces = append(surfaces, - detector.windowsAppSurface("chatgpt_desktop", filepath.Join("Microsoft", "WindowsApps", "ChatGPT.exe"), filepath.Join("WindowsApps", "ChatGPT.exe")), - ) + surfaces = append(surfaces, detector.appSurface("codex_desktop", "Codex.app")) } else if detector.GOOS == "linux" { - surfaces = append(surfaces, detector.linuxDesktopSurface("codex_desktop", "codex.desktop", "chatgpt.desktop")) + surfaces = append(surfaces, detector.linuxDesktopSurface("codex_desktop", "codex.desktop")) } - return detected(domain.ClientCodex, "OpenAI Codex / ChatGPT", configRoot, detector.lookup("codex"), surfaces) + return detected(domain.ClientCodex, "OpenAI Codex", configRoot, detector.lookup("codex"), surfaces) +} + +func (detector Detector) detectChatGPT() domain.DetectedClient { + var surfaces []domain.ClientSurface + switch detector.GOOS { + case "darwin": + surfaces = append(surfaces, detector.appSurface("chatgpt_desktop", "ChatGPT.app")) + case "windows": + surfaces = append(surfaces, detector.windowsAppSurface( + "chatgpt_desktop", + filepath.Join("Microsoft", "WindowsApps", "ChatGPT.exe"), + filepath.Join("WindowsApps", "ChatGPT.exe"), + )) + case "linux": + surfaces = append(surfaces, detector.linuxDesktopSurface("chatgpt_desktop", "chatgpt.desktop")) + } + // ChatGPT is a remote/manual host. It intentionally has no executable and + // does not inherit the Codex CLI or config directory. + return detected(domain.ClientChatGPT, "ChatGPT", "", "", surfaces) } func (detector Detector) detectCursor() domain.DetectedClient { diff --git a/install/integrationctl/agentplugins/adapters/clientdetect/detector_test.go b/install/integrationctl/agentplugins/adapters/clientdetect/detector_test.go index 89ff6e63..b8e7f4e5 100644 --- a/install/integrationctl/agentplugins/adapters/clientdetect/detector_test.go +++ b/install/integrationctl/agentplugins/adapters/clientdetect/detector_test.go @@ -19,8 +19,8 @@ func TestDetectorReturnsAllSupportedClientsWithoutAmbientDiscovery(t *testing.T) if err != nil { t.Fatal(err) } - if len(clients) != 5 { - t.Fatalf("clients = %d, want 5", len(clients)) + if len(clients) != 6 { + t.Fatalf("clients = %d, want 6", len(clients)) } for _, client := range clients { if client.Status != domain.DetectionNotDetected { @@ -29,6 +29,49 @@ func TestDetectorReturnsAllSupportedClientsWithoutAmbientDiscovery(t *testing.T) } } +func TestChatGPTDesktopNeverDetectsCodexOrInheritsItsBinary(t *testing.T) { + t.Parallel() + home := t.TempDir() + applications := filepath.Join(home, "Applications") + if err := os.MkdirAll(filepath.Join(applications, "ChatGPT.app"), 0o755); err != nil { + t.Fatal(err) + } + detector := testDetector(home, map[string]string{"codex": filepath.Join(home, "bin", "codex")}) + detector.GOOS = "darwin" + detector.SystemApplicationsDir = applications + clients, err := detector.Detect(context.Background()) + if err != nil { + t.Fatal(err) + } + chatgpt := clientOf(clients, domain.ClientChatGPT) + if chatgpt.Status != domain.DetectionDetected || chatgpt.ExecutablePath != "" || !surfaceDetected(chatgpt.Surfaces, "chatgpt_desktop") { + t.Fatalf("ChatGPT detection = %+v", chatgpt) + } + codex := clientOf(clients, domain.ClientCodex) + if !surfaceDetected(codex.Surfaces, "codex_cli") || surfaceDetected(codex.Surfaces, "chatgpt_desktop") { + t.Fatalf("Codex detection leaked ChatGPT surface: %+v", codex) + } +} + +func TestCodexDesktopNeverDetectsChatGPT(t *testing.T) { + t.Parallel() + home := t.TempDir() + applications := filepath.Join(home, "Applications") + if err := os.MkdirAll(filepath.Join(applications, "Codex.app"), 0o755); err != nil { + t.Fatal(err) + } + detector := testDetector(home, nil) + detector.GOOS = "darwin" + detector.SystemApplicationsDir = applications + clients, err := detector.Detect(context.Background()) + if err != nil { + t.Fatal(err) + } + if statusOf(clients, domain.ClientCodex) != domain.DetectionDetected || statusOf(clients, domain.ClientChatGPT) != domain.DetectionNotDetected { + t.Fatalf("split detection = %+v", clients) + } +} + func TestNewOSDoesNotProbeRelativeLocalApplicationsWithoutHome(t *testing.T) { t.Setenv("XDG_DATA_HOME", "") detector := NewOS("") diff --git a/install/integrationctl/agentplugins/adapters/loader/app.go b/install/integrationctl/agentplugins/adapters/loader/app.go new file mode 100644 index 00000000..bcf6ffcb --- /dev/null +++ b/install/integrationctl/agentplugins/adapters/loader/app.go @@ -0,0 +1,107 @@ +package loader + +import ( + "encoding/json" + "fmt" + "regexp" + "sort" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +var ( + appAliasPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + appIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._~:-]{0,255}$`) +) + +func (loader Loader) loadApp(path string, declared, acceptUndeclared bool) (domain.AppComponent, []domain.Diagnostic) { + body, exists, err := readRegularFile(path) + component := domain.AppComponent{Present: exists, Declared: declared, Raw: append(json.RawMessage(nil), body...)} + if !exists { + if declared { + return component, []domain.Diagnostic{appDiagnostic("app_manifest_missing", "official manifest declares .app.json but the file is missing", nil)} + } + return component, nil + } + if err != nil { + return component, []domain.Diagnostic{appDiagnostic("app_manifest_read_failed", "read root .app.json", err)} + } + if !declared && !acceptUndeclared { + return component, []domain.Diagnostic{{ + Severity: domain.SeverityWarning, Boundary: domain.BoundaryApp, + Code: "undeclared_app_manifest_ignored", Path: ".app.json", + Message: "root .app.json is ignored because .codex-plugin/plugin.json does not declare apps", + }} + } + component.Declared = true + rawFields, _, err := decodeJSONObject(body) + if err != nil { + return component, []domain.Diagnostic{appDiagnostic("app_manifest_malformed", "parse root .app.json", err)} + } + appsRaw, ok := rawFields["apps"] + if !ok { + return component, []domain.Diagnostic{appDiagnostic("app_entries_missing", ".app.json requires an apps object", nil)} + } + var entries map[string]json.RawMessage + if err := decodeJSON(appsRaw, &entries); err != nil || entries == nil { + return component, []domain.Diagnostic{appDiagnostic("app_entries_invalid", ".app.json apps must be an object", err)} + } + if len(entries) == 0 { + return component, []domain.Diagnostic{appDiagnostic("app_entries_empty", ".app.json apps must contain at least one registered connection", nil)} + } + + component.Bindings = make(map[string]domain.AppBinding, len(entries)) + names := make([]string, 0, len(entries)) + for name := range entries { + names = append(names, name) + } + sort.Strings(names) + var diagnostics []domain.Diagnostic + for _, name := range names { + raw := entries[name] + if !appAliasPattern.MatchString(name) { + diagnostics = append(diagnostics, appEntryDiagnostic(name, "app_alias_invalid", "app alias must use letters, digits, dot, underscore, or hyphen")) + continue + } + var entry map[string]json.RawMessage + if err := decodeJSON(raw, &entry); err != nil || entry == nil { + diagnostics = append(diagnostics, appEntryDiagnostic(name, "app_entry_invalid", "app entry must be an object")) + continue + } + var id string + if rawID, ok := entry["id"]; !ok || json.Unmarshal(rawID, &id) != nil || !appIDPattern.MatchString(id) { + diagnostics = append(diagnostics, appEntryDiagnostic(name, "app_id_invalid", "app id must be a non-empty opaque safe ASCII token")) + continue + } + binding := domain.AppBinding{Alias: name, ID: id, Raw: append(json.RawMessage(nil), raw...)} + valid := true + for _, field := range []struct { + name string + target *bool + }{ + {name: "optional", target: &binding.Optional}, + {name: "required", target: &binding.Required}, + } { + if value, ok := entry[field.name]; ok && json.Unmarshal(value, field.target) != nil { + diagnostics = append(diagnostics, appEntryDiagnostic(name, "app_entry_"+field.name+"_invalid", fmt.Sprintf("app entry %s must be a boolean", field.name))) + valid = false + } + } + if valid { + component.Bindings[name] = binding + } + } + component.Enabled = len(component.Bindings) == len(entries) + return component, diagnostics +} + +func appDiagnostic(code, message string, cause error) domain.Diagnostic { + if cause != nil { + message += ": " + cause.Error() + } + return domain.Diagnostic{Severity: domain.SeverityError, Boundary: domain.BoundaryApp, Code: code, Path: ".app.json", Message: message} +} + +func appEntryDiagnostic(name, code, message string) domain.Diagnostic { + return domain.Diagnostic{Severity: domain.SeverityError, Boundary: domain.BoundaryApp, Code: code, Path: ".app.json", Item: name, Message: message} +} diff --git a/install/integrationctl/agentplugins/adapters/loader/loader.go b/install/integrationctl/agentplugins/adapters/loader/loader.go index 02610dd6..1277e75f 100644 --- a/install/integrationctl/agentplugins/adapters/loader/loader.go +++ b/install/integrationctl/agentplugins/adapters/loader/loader.go @@ -34,19 +34,72 @@ func (loader Loader) Load(ctx context.Context, input domain.LoadInput) (domain.P if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return domain.PackageEnvelope{}, domain.FatalLoad("snapshot_invalid", "plugin.json", "package snapshot root must be a real directory", err) } - manifest, diagnostics, manifestDigest, err := loader.loadPluginManifest(filepath.Join(root, "plugin.json")) + if err := rejectDiscoverableHooks(root); err != nil { + return domain.PackageEnvelope{}, domain.FatalLoad( + "official_hooks_unsupported", "hooks/hooks.json", + "lifecycle hooks are auto-discovered by official clients but are not modeled by agentplugins v0.1; remove the hooks directory before installation", err, + ) + } + manifestPath := filepath.Join(root, "plugin.json") + _, portable, probeErr := readRegularFile(manifestPath) + if probeErr != nil { + return domain.PackageEnvelope{}, domain.FatalLoad("plugin_manifest_read_failed", "plugin.json", "read root plugin.json", probeErr) + } + formatID := domain.FormatIDAgentPluginsV1 + schemaVersion := "1.0.0" + mcpPath := filepath.Join(root, "mcp.json") + appPath := filepath.Join(root, ".app.json") + skillsPath := filepath.Join(root, "skills") + appDeclared, acceptUndeclaredApp := false, true + mcpDeclared := true + + var manifest domain.PluginManifest + var diagnostics []domain.Diagnostic + var manifestDigest string + if portable { + manifest, diagnostics, manifestDigest, err = loader.loadPluginManifest(manifestPath) + } else { + formatID = domain.FormatIDOpenAIPlugin + schemaVersion = "" + var components openAIComponentPaths + manifest, components, diagnostics, manifestDigest, err = loader.loadOpenAIPluginManifest(filepath.Join(root, ".codex-plugin", "plugin.json")) + mcpDeclared = components.MCP + appDeclared = components.App + acceptUndeclaredApp = false + if !components.Skills { + skillsPath = "" + } + } if err != nil { return domain.PackageEnvelope{}, err } - mcp, mcpDiagnostics := loader.loadMCP(filepath.Join(root, "mcp.json")) + + var mcp domain.MCPComponent + var mcpDiagnostics []domain.Diagnostic + if portable { + mcp, mcpDiagnostics = loader.loadMCP(mcpPath) + } else { + mcpPath = filepath.Join(root, ".mcp.json") + mcp, mcpDiagnostics = loader.loadOpenAIMCP(mcpPath, mcpDeclared) + } diagnostics = append(diagnostics, mcpDiagnostics...) - skills, invalidSkills, invalidSkillsRoot, skillDiagnostics := loadSkills(filepath.Join(root, "skills")) + app, appDiagnostics := loader.loadApp(appPath, appDeclared, acceptUndeclaredApp) + diagnostics = append(diagnostics, appDiagnostics...) + var skills map[string]domain.Skill + var invalidSkills []string + var invalidSkillsRoot bool + var skillDiagnostics []domain.Diagnostic + if skillsPath != "" { + skills, invalidSkills, invalidSkillsRoot, skillDiagnostics = loadSkills(skillsPath) + } diagnostics = append(diagnostics, skillDiagnostics...) inventory := domain.ComponentInventory{ MCPPresent: mcp.Present, MCPEnabled: mcp.Enabled, MCPServers: sortedMCPServerNames(mcp.Servers), + AppPresent: app.Present, + AppBindings: sortedAppBindingNames(app.Bindings), Skills: sortedSkillNames(skills), InvalidSkills: invalidSkills, InvalidSkillsRoot: invalidSkillsRoot, @@ -61,12 +114,13 @@ func (loader Loader) Load(ctx context.Context, input domain.LoadInput) (domain.P sort.Strings(executableFiles) return domain.PackageEnvelope{ LoaderKind: domain.LoaderKindAgentPlugins, - FormatID: domain.FormatIDAgentPluginsV1, + FormatID: formatID, SchemaURI: manifest.SchemaURI, - SchemaVersion: "1.0.0", - ManifestSchema: domain.SchemaIdentity{URI: manifest.SchemaURI, Version: "1.0.0"}, + SchemaVersion: schemaVersion, + ManifestSchema: domain.SchemaIdentity{URI: manifest.SchemaURI, Version: schemaVersion}, Manifest: manifest, MCP: mcp, + App: app, Skills: skills, Inventory: inventory, Diagnostics: diagnostics, @@ -78,6 +132,19 @@ func (loader Loader) Load(ctx context.Context, input domain.LoadInput) (domain.P }, nil } +func rejectDiscoverableHooks(root string) error { + entries, err := os.ReadDir(root) + if err != nil { + return fmt.Errorf("inspect package root for auto-discovered hooks: %w", err) + } + for _, entry := range entries { + if strings.EqualFold(entry.Name(), "hooks") { + return fmt.Errorf("package root contains auto-discoverable hooks path %q", entry.Name()) + } + } + return nil +} + func readRegularFile(path string) ([]byte, bool, error) { info, err := os.Lstat(path) if os.IsNotExist(err) { @@ -116,6 +183,15 @@ func sortedMCPServerNames(values map[string]domain.MCPServer) []string { return keys } +func sortedAppBindingNames(values map[string]domain.AppBinding) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + func sortedSkillNames(values map[string]domain.Skill) []string { keys := make([]string, 0, len(values)) for key := range values { diff --git a/install/integrationctl/agentplugins/adapters/loader/loader_test.go b/install/integrationctl/agentplugins/adapters/loader/loader_test.go index a89ce156..77a6b93a 100644 --- a/install/integrationctl/agentplugins/adapters/loader/loader_test.go +++ b/install/integrationctl/agentplugins/adapters/loader/loader_test.go @@ -37,6 +37,170 @@ func TestLoadMinimalPlugin(t *testing.T) { } } +func TestLoadPortableAppManifestIsTypedAndLossless(t *testing.T) { + t.Parallel() + root := t.TempDir() + writeMinimalPlugin(t, root, "portable-app") + app := `{"apps":{"docs":{"id":"asdk_app_docs_123","required":true,"future":{"kept":true}}}}` + writeLoaderFile(t, filepath.Join(root, ".app.json"), app) + + envelope, err := testLoader(t).Load(context.Background(), domain.LoadInput{SnapshotRoot: root}) + if err != nil { + t.Fatal(err) + } + if !envelope.App.Present || !envelope.App.Enabled || envelope.App.Bindings["docs"].ID != "asdk_app_docs_123" { + t.Fatalf("app component = %+v", envelope.App) + } + if string(envelope.App.Raw) != app || len(envelope.App.Bindings["docs"].Raw) == 0 { + t.Fatal("app manifest was not preserved losslessly") + } + if !envelope.Inventory.AppPresent || len(envelope.Inventory.AppBindings) != 1 || envelope.Inventory.AppBindings[0] != "docs" { + t.Fatalf("inventory = %+v", envelope.Inventory) + } +} + +func TestLoadOfficialOpenAIPackageWithAppAndMCP(t *testing.T) { + t.Parallel() + root := t.TempDir() + manifest := `{ + "name":"official-demo", + "version":"1.2.3", + "skills":"./skills/", + "mcpServers":"./.mcp.json", + "apps":"./.app.json", + "interface":{"displayName":"Official Demo"}, + "future":{"preserved":true} +}` + writeLoaderFile(t, filepath.Join(root, ".codex-plugin", "plugin.json"), manifest) + writeLoaderFile(t, filepath.Join(root, ".mcp.json"), `{"mcp_servers":{"docs":{"url":"https://example.test/mcp"}}}`) + writeLoaderFile(t, filepath.Join(root, ".app.json"), `{"apps":{"docs":{"id":"plugin_asdk_app_docs_123"}}}`) + writeLoaderFile(t, filepath.Join(root, "skills", "docs", "SKILL.md"), "---\nname: docs\ndescription: Docs workflow\n---\nUse docs.\n") + + envelope, err := testLoader(t).Load(context.Background(), domain.LoadInput{SnapshotRoot: root, TreeDigest: "sha256:tree"}) + if err != nil { + t.Fatal(err) + } + if envelope.FormatID != domain.FormatIDOpenAIPlugin || envelope.SchemaURI != "" || envelope.Manifest.Name != "official-demo" { + t.Fatalf("official identity = %+v", envelope) + } + if string(envelope.Manifest.Raw) != manifest || envelope.Manifest.Unknown["future"] == nil { + t.Fatal("official manifest fields were not preserved") + } + if envelope.MCP.Servers["docs"].Type != "streamable-http" || !envelope.App.Enabled || len(envelope.Skills) != 1 { + t.Fatalf("official components = mcp=%+v app=%+v skills=%+v", envelope.MCP, envelope.App, envelope.Skills) + } +} + +func TestLoadOfficialPackageReportsMissingDeclaredApp(t *testing.T) { + t.Parallel() + root := t.TempDir() + writeLoaderFile(t, filepath.Join(root, ".codex-plugin", "plugin.json"), `{"name":"missing-app","apps":"./.app.json"}`) + envelope, err := testLoader(t).Load(context.Background(), domain.LoadInput{SnapshotRoot: root}) + if err != nil { + t.Fatal(err) + } + if !envelope.App.Declared || envelope.App.Enabled || !hasDiagnostic(envelope.Diagnostics, "app_manifest_missing") { + t.Fatalf("app boundary = %+v diagnostics=%+v", envelope.App, envelope.Diagnostics) + } +} + +func TestPortableManifestTakesPrecedenceOverOfficialManifest(t *testing.T) { + t.Parallel() + root := t.TempDir() + writeMinimalPlugin(t, root, "portable-wins") + writeLoaderFile(t, filepath.Join(root, ".codex-plugin", "plugin.json"), `{"name":"official-loses","apps":"./.app.json"}`) + + envelope, err := testLoader(t).Load(context.Background(), domain.LoadInput{SnapshotRoot: root}) + if err != nil { + t.Fatal(err) + } + if envelope.FormatID != domain.FormatIDAgentPluginsV1 || envelope.Manifest.Name != "portable-wins" || envelope.App.Declared { + t.Fatalf("portable precedence = %+v", envelope) + } +} + +func TestLoadOfficialPackageRejectsEscapingPreservedPaths(t *testing.T) { + t.Parallel() + for name, manifest := range map[string]string{ + "hooks": `{"name":"unsafe-hooks","hooks":"./../outside.json"}`, + "asset": `{"name":"unsafe-asset","interface":{"logo":"./assets/../../outside.png"}}`, + "dark-asset": `{"name":"unsafe-dark-asset","interface":{"logoDark":"./assets/../../outside.png"}}`, + "screenshots": `{"name":"unsafe-screenshots","interface":{"screenshots":["https://example.test/image.png"]}}`, + } { + name, manifest := name, manifest + t.Run(name, func(t *testing.T) { + root := t.TempDir() + writeLoaderFile(t, filepath.Join(root, ".codex-plugin", "plugin.json"), manifest) + if _, err := testLoader(t).Load(context.Background(), domain.LoadInput{SnapshotRoot: root}); err == nil { + t.Fatal("unsafe official manifest path accepted") + } + }) + } +} + +func TestLoadOfficialPackageRejectsLifecycleHooksUntilModeled(t *testing.T) { + t.Parallel() + root := t.TempDir() + writeLoaderFile(t, filepath.Join(root, ".codex-plugin", "plugin.json"), `{"name":"hooked","hooks":"./hooks/session.json"}`) + writeLoaderFile(t, filepath.Join(root, "hooks", "session.json"), `{}`) + + _, err := testLoader(t).Load(context.Background(), domain.LoadInput{SnapshotRoot: root}) + var loadErr *domain.LoadError + if !errors.As(err, &loadErr) || loadErr.Diagnostic.Code != "official_hooks_unsupported" { + t.Fatalf("hook package error = %v", err) + } +} + +func TestLoadRejectsImplicitLifecycleHooksForPortableAndOfficialPackages(t *testing.T) { + t.Parallel() + for _, format := range []string{"portable", "official"} { + format := format + t.Run(format, func(t *testing.T) { + root := t.TempDir() + if format == "portable" { + writeLoaderFile(t, filepath.Join(root, "plugin.json"), `{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"implicit-hooks","version":"1.0.0","description":"Implicit hooks"}`) + } else { + writeLoaderFile(t, filepath.Join(root, ".codex-plugin", "plugin.json"), `{"name":"implicit-hooks"}`) + } + writeLoaderFile(t, filepath.Join(root, "hooks", "hooks.json"), `{}`) + + _, err := testLoader(t).Load(context.Background(), domain.LoadInput{SnapshotRoot: root}) + var loadErr *domain.LoadError + if !errors.As(err, &loadErr) || loadErr.Diagnostic.Code != "official_hooks_unsupported" || !strings.Contains(err.Error(), "remove the hooks directory") { + t.Fatalf("implicit %s hooks error = %v", format, err) + } + }) + } +} + +func TestLoadAppIDsAreOpaqueSafeTokens(t *testing.T) { + t.Parallel() + for _, id := range []string{"connector_68df038e0ba48191908c8434991bbac2", "asdk_app_69c18c28f1188191bf5b8445c4ab0a2e", "plugin_asdk_app_current", "future:v2~opaque.value"} { + id := id + t.Run(id, func(t *testing.T) { + root := t.TempDir() + writeLoaderFile(t, filepath.Join(root, ".codex-plugin", "plugin.json"), `{"name":"opaque-app","apps":"./.app.json"}`) + writeLoaderFile(t, filepath.Join(root, ".app.json"), `{"apps":{"opaque":{"id":"`+id+`"}}}`) + envelope, err := testLoader(t).Load(context.Background(), domain.LoadInput{SnapshotRoot: root}) + if err != nil || !envelope.App.Enabled { + t.Fatalf("opaque app id %q rejected: %+v, %v", id, envelope.App, err) + } + }) + } + for _, id := range []string{" leading", "contains space", "../escape", "slash/value"} { + id := id + t.Run("reject-"+id, func(t *testing.T) { + root := t.TempDir() + writeLoaderFile(t, filepath.Join(root, ".codex-plugin", "plugin.json"), `{"name":"bad-app","apps":"./.app.json"}`) + writeLoaderFile(t, filepath.Join(root, ".app.json"), `{"apps":{"bad":{"id":"`+id+`"}}}`) + envelope, err := testLoader(t).Load(context.Background(), domain.LoadInput{SnapshotRoot: root}) + if err != nil || envelope.App.Enabled || !hasDiagnostic(envelope.Diagnostics, "app_id_invalid") { + t.Fatalf("unsafe app id %q accepted: %+v, %v", id, envelope.App, err) + } + }) + } +} + func TestLoadFullPluginPreservesExtensionsAndUnknownFields(t *testing.T) { t.Parallel() root := t.TempDir() diff --git a/install/integrationctl/agentplugins/adapters/loader/mcp.go b/install/integrationctl/agentplugins/adapters/loader/mcp.go index d01fdf27..7277e595 100644 --- a/install/integrationctl/agentplugins/adapters/loader/mcp.go +++ b/install/integrationctl/agentplugins/adapters/loader/mcp.go @@ -97,6 +97,110 @@ func (loader Loader) loadMCP(path string) (domain.MCPComponent, []domain.Diagnos return component, diagnostics } +func (loader Loader) loadOpenAIMCP(path string, declared bool) (domain.MCPComponent, []domain.Diagnostic) { + body, exists, err := readRegularFile(path) + component := domain.MCPComponent{ + Present: exists, Raw: append(json.RawMessage(nil), body...), + Servers: map[string]domain.MCPServer{}, InvalidServer: map[string]domain.Diagnostic{}, + } + if !exists { + if declared { + return component, []domain.Diagnostic{openAIMCPDiagnostic("mcp_manifest_missing", "official manifest declares .mcp.json but the file is missing", nil)} + } + return component, nil + } + if err != nil { + return component, []domain.Diagnostic{openAIMCPDiagnostic("mcp_read_failed", "read root .mcp.json", err)} + } + if !declared { + return component, []domain.Diagnostic{{ + Severity: domain.SeverityWarning, Boundary: domain.BoundaryMCP, + Code: "undeclared_mcp_manifest_ignored", Path: ".mcp.json", + Message: "root .mcp.json is ignored because .codex-plugin/plugin.json does not declare mcpServers", + }} + } + rawFields, _, err := decodeJSONObject(body) + if err != nil { + return component, []domain.Diagnostic{openAIMCPDiagnostic("mcp_malformed", "parse root .mcp.json", err)} + } + serverDocuments := rawFields + for _, wrapper := range []string{"mcp_servers", "mcpServers"} { + if wrapped, ok := rawFields[wrapper]; ok { + if len(rawFields) != 1 { + return component, []domain.Diagnostic{openAIMCPDiagnostic("mcp_servers_invalid", "wrapped .mcp.json cannot contain sibling fields", nil)} + } + if err := decodeJSON(wrapped, &serverDocuments); err != nil || serverDocuments == nil { + return component, []domain.Diagnostic{openAIMCPDiagnostic("mcp_servers_invalid", wrapper+" must be an object", err)} + } + break + } + } + component.Enabled = true + names := make([]string, 0, len(serverDocuments)) + for name := range serverDocuments { + names = append(names, name) + } + sort.Strings(names) + var diagnostics []domain.Diagnostic + for _, name := range names { + raw := serverDocuments[name] + var config map[string]any + if err := decodeJSON(raw, &config); err != nil || config == nil { + diagnostic := invalidOfficialMCP(name, "server config must be an object") + component.InvalidServer[name] = diagnostic + diagnostics = append(diagnostics, diagnostic) + continue + } + typeName, valid := officialMCPType(config) + if !valid { + diagnostic := invalidOfficialMCP(name, "server requires a command for stdio or a URL for http/sse") + component.InvalidServer[name] = diagnostic + diagnostics = append(diagnostics, diagnostic) + continue + } + component.Servers[name] = domain.MCPServer{Name: name, Type: typeName, Raw: append(json.RawMessage(nil), raw...), Decoded: config} + } + return component, diagnostics +} + +func openAIMCPDiagnostic(code, message string, cause error) domain.Diagnostic { + if cause != nil { + message += ": " + cause.Error() + } + return domain.Diagnostic{Severity: domain.SeverityError, Boundary: domain.BoundaryMCP, Code: code, Path: ".mcp.json", Message: message} +} + +func officialMCPType(config map[string]any) (string, bool) { + typeName, _ := config["type"].(string) + switch strings.ToLower(strings.TrimSpace(typeName)) { + case "stdio": + _, ok := config["command"].(string) + return "stdio", ok + case "http", "streamable-http": + _, ok := config["url"].(string) + return "streamable-http", ok + case "sse": + _, ok := config["url"].(string) + return "sse", ok + case "": + if _, ok := config["command"].(string); ok { + return "stdio", true + } + if _, ok := config["url"].(string); ok { + return "streamable-http", true + } + } + return "", false +} + +func invalidOfficialMCP(name, message string) domain.Diagnostic { + return domain.Diagnostic{ + Severity: domain.SeverityError, Boundary: domain.BoundaryMCPServer, + Code: "mcp_server_invalid", Path: ".mcp.json", Item: name, + Message: fmt.Sprintf("MCP server %q was skipped because %s", name, message), + } +} + func mcpDiagnostic(code, message string, cause error) domain.Diagnostic { if cause != nil { message += ": " + cause.Error() diff --git a/install/integrationctl/agentplugins/adapters/loader/openai_plugin.go b/install/integrationctl/agentplugins/adapters/loader/openai_plugin.go new file mode 100644 index 00000000..1618092d --- /dev/null +++ b/install/integrationctl/agentplugins/adapters/loader/openai_plugin.go @@ -0,0 +1,182 @@ +package loader + +import ( + "encoding/json" + "fmt" + "path" + "strings" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/adapters/pathpolicy" + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +type openAIComponentPaths struct { + Skills bool + MCP bool + App bool +} + +var openAIManifestFields = map[string]struct{}{ + "name": {}, "version": {}, "description": {}, "author": {}, "homepage": {}, + "repository": {}, "license": {}, "keywords": {}, "skills": {}, + "mcpServers": {}, "apps": {}, "hooks": {}, "interface": {}, "$schema": {}, +} + +func (loader Loader) loadOpenAIPluginManifest(path string) (domain.PluginManifest, openAIComponentPaths, []domain.Diagnostic, string, error) { + body, exists, err := readRegularFile(path) + if err != nil { + return domain.PluginManifest{}, openAIComponentPaths{}, nil, "", domain.FatalLoad("plugin_manifest_read_failed", ".codex-plugin/plugin.json", "read official OpenAI plugin manifest", err) + } + if !exists { + return domain.PluginManifest{}, openAIComponentPaths{}, nil, "", domain.FatalLoad("plugin_manifest_missing", "plugin.json", "expected root plugin.json or .codex-plugin/plugin.json", nil) + } + rawFields, _, err := decodeJSONObject(body) + if err != nil { + return domain.PluginManifest{}, openAIComponentPaths{}, nil, "", domain.FatalLoad("plugin_manifest_malformed", ".codex-plugin/plugin.json", "parse official OpenAI plugin manifest", err) + } + var typed struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Author *domain.Author `json:"author"` + Homepage string `json:"homepage"` + Repository string `json:"repository"` + License string `json:"license"` + Keywords []string `json:"keywords"` + } + if err := json.Unmarshal(body, &typed); err != nil { + return domain.PluginManifest{}, openAIComponentPaths{}, nil, "", domain.FatalLoad("plugin_manifest_decode_failed", ".codex-plugin/plugin.json", "decode official OpenAI plugin manifest", err) + } + if err := pathpolicy.ValidateLeafID(typed.Name); err != nil { + return domain.PluginManifest{}, openAIComponentPaths{}, nil, "", domain.FatalLoad("plugin_name_unsafe", ".codex-plugin/plugin.json", "plugin name cannot be used as a portable physical identifier", err) + } + components := openAIComponentPaths{} + for _, accepted := range []struct { + field string + path string + present *bool + }{ + {field: "skills", path: "./skills/", present: &components.Skills}, + {field: "mcpServers", path: "./.mcp.json", present: &components.MCP}, + {field: "apps", path: "./.app.json", present: &components.App}, + } { + raw, ok := rawFields[accepted.field] + if !ok { + continue + } + var value string + if err := json.Unmarshal(raw, &value); err != nil || !canonicalComponentPath(value, accepted.path) { + return domain.PluginManifest{}, components, nil, "", domain.FatalLoad( + "official_component_path_unsupported", ".codex-plugin/plugin.json", + fmt.Sprintf("%s must reference %s", accepted.field, accepted.path), err, + ) + } + *accepted.present = true + } + if raw, ok := rawFields["hooks"]; ok { + if err := validateOfficialHooks(raw); err != nil { + return domain.PluginManifest{}, components, nil, "", domain.FatalLoad("official_component_path_unsafe", ".codex-plugin/plugin.json", err.Error(), err) + } + return domain.PluginManifest{}, components, nil, "", domain.FatalLoad( + "official_hooks_unsupported", ".codex-plugin/plugin.json", + "official lifecycle hooks are not supported by agentplugins v0.1 and cannot be installed safely", nil, + ) + } + if raw, ok := rawFields["interface"]; ok { + if err := validateOfficialInterfacePaths(raw); err != nil { + return domain.PluginManifest{}, components, nil, "", domain.FatalLoad("official_asset_path_unsafe", ".codex-plugin/plugin.json", err.Error(), err) + } + } + unknown := map[string]json.RawMessage{} + for key, raw := range rawFields { + if _, known := openAIManifestFields[key]; !known { + unknown[key] = append(json.RawMessage(nil), raw...) + } + } + return domain.PluginManifest{ + Name: typed.Name, Version: typed.Version, Description: typed.Description, + Author: typed.Author, Homepage: typed.Homepage, Repository: typed.Repository, + License: typed.License, Keywords: append([]string(nil), typed.Keywords...), + Unknown: unknown, Raw: append(json.RawMessage(nil), body...), + }, components, nil, sha256Digest(body), nil +} + +func validateOfficialHooks(raw json.RawMessage) error { + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return fmt.Errorf("hooks must be a path, inline object, or an array of paths and inline objects") + } + validate := func(item any) error { + switch typed := item.(type) { + case string: + if err := validateOfficialRelativePath(typed); err != nil { + return fmt.Errorf("hook paths must be safe ./-prefixed paths inside the plugin root") + } + case map[string]any: + return nil + default: + return fmt.Errorf("hooks must contain only paths or inline hook objects") + } + return nil + } + if values, ok := value.([]any); ok { + for _, item := range values { + if err := validate(item); err != nil { + return err + } + } + return nil + } + return validate(value) +} + +func validateOfficialInterfacePaths(raw json.RawMessage) error { + var value map[string]json.RawMessage + if err := json.Unmarshal(raw, &value); err != nil || value == nil { + return fmt.Errorf("interface must be an object") + } + for _, field := range []string{"composerIcon", "logo", "logoDark"} { + if rawPath, ok := value[field]; ok { + var candidate string + if err := json.Unmarshal(rawPath, &candidate); err != nil || validateOfficialRelativePath(candidate) != nil { + return fmt.Errorf("interface.%s must be a safe ./-prefixed path inside the plugin root", field) + } + } + } + if rawScreenshots, ok := value["screenshots"]; ok { + var screenshots []string + if err := json.Unmarshal(rawScreenshots, &screenshots); err != nil { + return fmt.Errorf("interface.screenshots must be an array of safe plugin-relative paths") + } + for _, candidate := range screenshots { + if err := validateOfficialRelativePath(candidate); err != nil { + return fmt.Errorf("interface.screenshots must contain only safe ./-prefixed paths inside the plugin root") + } + } + } + return nil +} + +func validateOfficialRelativePath(value string) error { + if !strings.HasPrefix(value, "./") || strings.Contains(value, `\`) || strings.ContainsRune(value, '\x00') { + return fmt.Errorf("path must start with ./ and use forward slashes") + } + relative := strings.TrimSuffix(strings.TrimPrefix(value, "./"), "/") + if relative == "" || path.IsAbs(relative) || path.Clean(relative) != relative { + return fmt.Errorf("path must remain inside the plugin root") + } + for _, segment := range strings.Split(relative, "/") { + if err := pathpolicy.ValidatePortablePathSegment(segment); err != nil { + return err + } + } + return nil +} + +func canonicalComponentPath(value, want string) bool { + value = strings.TrimSpace(value) + if want == "./skills/" { + return value == want || value == "./skills" + } + return value == want +} diff --git a/install/integrationctl/agentplugins/adapters/statemigration/migrate.go b/install/integrationctl/agentplugins/adapters/statemigration/migrate.go index 55ec0c7b..99ba5158 100644 --- a/install/integrationctl/agentplugins/adapters/statemigration/migrate.go +++ b/install/integrationctl/agentplugins/adapters/statemigration/migrate.go @@ -149,17 +149,17 @@ func (migrator Migrator) MigrateExpected(expectedDigest string) (Report, error) } report.BackupPath = backupPath if err := migrator.V2Store.Save(state); err != nil { - return report, fmt.Errorf("commit migrated state v2: %w", err) + return report, fmt.Errorf("commit migrated Agent Plugins state: %w", err) } return report, nil } func (migrator Migrator) load() ([]byte, legacyState, error) { if strings.TrimSpace(migrator.LegacyPath) == "" || strings.TrimSpace(migrator.V2Store.Path) == "" { - return nil, legacyState{}, fmt.Errorf("legacy and state v2 paths are required") + return nil, legacyState{}, fmt.Errorf("legacy and Agent Plugins state paths are required") } if _, err := os.Stat(migrator.V2Store.Path); err == nil { - return nil, legacyState{}, fmt.Errorf("state v2 already exists; migration is not idempotent over an authoritative v2 state") + return nil, legacyState{}, fmt.Errorf("Agent Plugins state already exists; migration is not idempotent over authoritative state") } else if !os.IsNotExist(err) { return nil, legacyState{}, err } diff --git a/install/integrationctl/agentplugins/adapters/statemigration/migrate_test.go b/install/integrationctl/agentplugins/adapters/statemigration/migrate_test.go index 62ebe1b8..940553c4 100644 --- a/install/integrationctl/agentplugins/adapters/statemigration/migrate_test.go +++ b/install/integrationctl/agentplugins/adapters/statemigration/migrate_test.go @@ -77,6 +77,9 @@ func TestMigrateCopiesLegacyStateAndPreservesLegacyLoaderBinding(t *testing.T) { t.Fatalf("installation = %+v", installation) } for _, client := range installation.Clients { + if client.ClientID != string(domain.ClientCodex) { + t.Fatalf("legacy codex binding was reclassified: %+v", client) + } if client.Materialization != domain.MaterializationMaterialized || client.Activation != domain.ActivationManual || client.Verification != domain.VerificationNotRun { t.Fatalf("client states = %+v", client) } diff --git a/install/integrationctl/agentplugins/adapters/statev2/legacy_v2.go b/install/integrationctl/agentplugins/adapters/statev2/legacy_v2.go new file mode 100644 index 00000000..eb55c329 --- /dev/null +++ b/install/integrationctl/agentplugins/adapters/statev2/legacy_v2.go @@ -0,0 +1,123 @@ +package statev2 + +import ( + "encoding/json" + "fmt" + + "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" +) + +// These types intentionally freeze the exact state schema written by +// agentplugins 0.1.4 and 0.1.5. Do not add v3 fields here. +type legacyStateFileV2 struct { + SchemaVersion int `json:"schema_version"` + Installations []legacyInstallationV2 `json:"installations"` +} + +type legacyInstallationV2 struct { + InstallationID string `json:"installation_id"` + DeclaredName string `json:"declared_name"` + Source legacySourceBindingV2 `json:"source"` + Package legacyPackageBindingV2 `json:"package"` + Clients map[string]legacyClientBindingV2 `json:"clients"` + NeedsRebind bool `json:"needs_rebind,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type legacySourceBindingV2 struct { + SourceBindingID string `json:"source_binding_id"` + RequestedSource string `json:"requested_source"` + CanonicalSource string `json:"canonical_source"` + Repository string `json:"repository,omitempty"` + PackageSubpath string `json:"package_subpath,omitempty"` + ResolvedRevision string `json:"resolved_revision"` + TreeDigest string `json:"tree_digest"` + Publisher string `json:"publisher,omitempty"` +} + +type legacyPackageBindingV2 struct { + LoaderKind string `json:"loader_kind"` + FormatID string `json:"format_id"` + SchemaURI string `json:"schema_uri"` + DeclaredName string `json:"declared_name"` + Version string `json:"version,omitempty"` + ManifestDigest string `json:"manifest_digest"` + Inventory legacyComponentInventoryV2 `json:"inventory"` +} + +type legacyComponentInventoryV2 struct { + MCPPresent bool `json:"mcp_present"` + MCPEnabled bool `json:"mcp_enabled"` + MCPServers []string `json:"mcp_servers,omitempty"` + InvalidMCPServer []string `json:"invalid_mcp_servers,omitempty"` + Skills []string `json:"skills,omitempty"` + InvalidSkills []string `json:"invalid_skills,omitempty"` + InvalidSkillsRoot bool `json:"invalid_skills_root,omitempty"` + Extensions []string `json:"extensions,omitempty"` +} + +type legacyClientBindingV2 struct { + ClientBindingID string `json:"client_binding_id"` + ClientID string `json:"client_id"` + Scope string `json:"scope"` + TargetLocator string `json:"target_locator"` + PhysicalArtifact string `json:"physical_artifact_id"` + Materialization domain.MaterializationState `json:"materialization"` + Activation domain.ActivationState `json:"activation"` + Authentication domain.AuthenticationState `json:"authentication"` + Policy domain.PolicyState `json:"policy"` + Verification domain.VerificationState `json:"verification"` + PackageRevision *legacyClientPackageRevisionV2 `json:"package_revision,omitempty"` + NativeObjects []legacyNativeObjectV2 `json:"native_objects,omitempty"` + Receipts []legacyMutationReceiptV2 `json:"receipts,omitempty"` + UpdatedAt string `json:"updated_at"` +} + +type legacyClientPackageRevisionV2 struct { + Version string `json:"version,omitempty"` + ResolvedRevision string `json:"resolved_revision,omitempty"` + TreeDigest string `json:"tree_digest"` + ManifestDigest string `json:"manifest_digest"` +} + +type legacyNativeObjectV2 struct { + ObjectID string `json:"object_id"` + Kind string `json:"kind"` + LogicalName string `json:"logical_name,omitempty"` + Path string `json:"path,omitempty"` + BeforeDigest string `json:"before_digest,omitempty"` + ManagedDigest string `json:"managed_digest,omitempty"` + ProtectionClass string `json:"protection_class"` + UserModified bool `json:"user_modified,omitempty"` +} + +type legacyMutationReceiptV2 struct { + OperationID string `json:"operation_id"` + Sequence int `json:"sequence"` + MutationType string `json:"mutation_type"` + ClientBindingID string `json:"client_binding_id"` + ActivePath string `json:"active_path,omitempty"` + StagingPath string `json:"staging_path,omitempty"` + BackupPath string `json:"backup_path,omitempty"` + BeforeDigest string `json:"before_digest,omitempty"` + AfterDigest string `json:"after_digest,omitempty"` + Phase string `json:"phase"` +} + +func decodeLegacyStateV2(body []byte) (domain.StateFileV2, error) { + var legacy legacyStateFileV2 + if err := decodeStrictJSON(body, &legacy); err != nil { + return domain.StateFileV2{}, fmt.Errorf("decode legacy state v2: %w", err) + } + legacy.SchemaVersion = domain.StateSchemaVersion + converted, err := json.Marshal(legacy) + if err != nil { + return domain.StateFileV2{}, fmt.Errorf("encode legacy state v2 migration: %w", err) + } + var state domain.StateFileV2 + if err := json.Unmarshal(converted, &state); err != nil { + return domain.StateFileV2{}, fmt.Errorf("convert legacy state v2: %w", err) + } + return state, nil +} diff --git a/install/integrationctl/agentplugins/adapters/statev2/store.go b/install/integrationctl/agentplugins/adapters/statev2/store.go index f911bb65..40b5efa1 100644 --- a/install/integrationctl/agentplugins/adapters/statev2/store.go +++ b/install/integrationctl/agentplugins/adapters/statev2/store.go @@ -36,20 +36,20 @@ func (store Store) Load() (domain.StateFileV2, error) { if err := json.Unmarshal(body, &header); err != nil { return domain.StateFileV2{}, fmt.Errorf("decode state v2 header: %w", err) } - if header.SchemaVersion != domain.StateSchemaVersion { - return domain.StateFileV2{}, fmt.Errorf("unsupported state v2 schema_version %d", header.SchemaVersion) - } - decoder := json.NewDecoder(bytes.NewReader(body)) - decoder.DisallowUnknownFields() var state domain.StateFileV2 - if err := decoder.Decode(&state); err != nil { - return domain.StateFileV2{}, fmt.Errorf("decode state v2: %w", err) + switch header.SchemaVersion { + case domain.LegacyStateSchemaVersion: + state, err = decodeLegacyStateV2(body) + case domain.StateSchemaVersion: + err = decodeStrictJSON(body, &state) + default: + return domain.StateFileV2{}, fmt.Errorf("unsupported state schema_version %d; this build reads %d and %d and writes only %d", header.SchemaVersion, domain.LegacyStateSchemaVersion, domain.StateSchemaVersion, domain.StateSchemaVersion) } - if err := decoder.Decode(&struct{}{}); err != io.EOF { - return domain.StateFileV2{}, fmt.Errorf("state v2 contains trailing JSON values") + if err != nil { + return domain.StateFileV2{}, err } if err := Validate(state); err != nil { - return domain.StateFileV2{}, fmt.Errorf("validate state v2: %w", err) + return domain.StateFileV2{}, fmt.Errorf("validate state: %w", err) } return state, nil } @@ -62,7 +62,7 @@ func (store Store) Save(state domain.StateFileV2) error { state.SchemaVersion = domain.StateSchemaVersion } if err := Validate(state); err != nil { - return fmt.Errorf("refuse invalid state v2: %w", err) + return fmt.Errorf("refuse invalid state: %w", err) } state.Installations = append([]domain.Installation(nil), state.Installations...) sort.Slice(state.Installations, func(i, j int) bool { @@ -83,6 +83,18 @@ func (store Store) Save(state domain.StateFileV2) error { return atomicfile.Write(store.Path, body, 0o600) } +func decodeStrictJSON(body []byte, target any) error { + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return fmt.Errorf("decode state: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return fmt.Errorf("state contains trailing JSON values") + } + return nil +} + func Validate(state domain.StateFileV2) error { if state.SchemaVersion != domain.StateSchemaVersion { return fmt.Errorf("schema_version must be %d", domain.StateSchemaVersion) @@ -111,9 +123,12 @@ func Validate(state domain.StateFileV2) error { } sourceBindingIDs[installation.Source.SourceBindingID] = struct{}{} if installation.Package.LoaderKind == domain.LoaderKindAgentPlugins { - if installation.Package.FormatID == "" || installation.Package.SchemaURI == "" || installation.Source.TreeDigest == "" || installation.Package.ManifestDigest == "" { + if installation.Package.FormatID == "" || installation.Source.TreeDigest == "" || installation.Package.ManifestDigest == "" { return fmt.Errorf("%s standard package binding is incomplete", prefix) } + if installation.Package.FormatID == domain.FormatIDAgentPluginsV1 && installation.Package.SchemaURI == "" { + return fmt.Errorf("%s portable standard package binding has no schema URI", prefix) + } } for mapKey, client := range installation.Clients { if mapKey == "" || mapKey != client.ClientBindingID { diff --git a/install/integrationctl/agentplugins/adapters/statev2/store_test.go b/install/integrationctl/agentplugins/adapters/statev2/store_test.go index d73e0a8a..15e9dd7a 100644 --- a/install/integrationctl/agentplugins/adapters/statev2/store_test.go +++ b/install/integrationctl/agentplugins/adapters/statev2/store_test.go @@ -3,7 +3,6 @@ package statev2 import ( "bytes" "encoding/json" - "io" "os" "path/filepath" "strings" @@ -12,27 +11,6 @@ import ( "github.com/777genius/plugin-kit-ai/install/integrationctl/agentplugins/domain" ) -type oldPackageBindingV2 struct { - LoaderKind string `json:"loader_kind"` - FormatID string `json:"format_id"` - SchemaURI string `json:"schema_uri"` - DeclaredName string `json:"declared_name"` - Version string `json:"version,omitempty"` - ManifestDigest string `json:"manifest_digest"` - Inventory domain.ComponentInventory `json:"inventory"` -} - -type oldInstallationV2 struct { - InstallationID string `json:"installation_id"` - DeclaredName string `json:"declared_name"` - Source domain.SourceBinding `json:"source"` - Package oldPackageBindingV2 `json:"package"` - Clients map[string]domain.ClientBinding `json:"clients"` - NeedsRebind bool `json:"needs_rebind,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - func TestStoreRoundTripAndDuplicateDeclaredNames(t *testing.T) { t.Parallel() store := Store{Path: filepath.Join(t.TempDir(), "state-v2.json")} @@ -62,11 +40,11 @@ func TestStoreRoundTripAndDuplicateDeclaredNames(t *testing.T) { } } -func TestNewlyWrittenStateDecodesWithStrictOldV2Shape(t *testing.T) { +func TestChatGPTStateIsExplicitlyV3AndOldReadersFailClosed(t *testing.T) { t.Parallel() store := Store{Path: filepath.Join(t.TempDir(), "state-v2.json")} state := domain.StateFileV2{SchemaVersion: domain.StateSchemaVersion, Installations: []domain.Installation{ - validInstallation("00000000-0000-4000-8000-000000000001", "src_one", "demo-000000000001"), + validChatGPTInstallation(), }} if err := store.Save(state); err != nil { t.Fatal(err) @@ -75,30 +53,84 @@ func TestNewlyWrittenStateDecodesWithStrictOldV2Shape(t *testing.T) { if err != nil { t.Fatal(err) } - decoder := json.NewDecoder(bytes.NewReader(body)) - decoder.DisallowUnknownFields() - var old struct { - SchemaVersion int `json:"schema_version"` - Installations []oldInstallationV2 `json:"installations"` + var header struct { + SchemaVersion int `json:"schema_version"` } - if err := decoder.Decode(&old); err != nil { - t.Fatalf("old 0.1.4 v2 decoder rejected new state: %v\n%s", err, body) + if err := json.Unmarshal(body, &header); err != nil { + t.Fatal(err) } - if err := decoder.Decode(&struct{}{}); err != io.EOF { - t.Fatalf("trailing state data: %v", err) + if header.SchemaVersion != domain.StateSchemaVersion || header.SchemaVersion == domain.LegacyStateSchemaVersion { + t.Fatalf("ChatGPT state was not gated behind schema v3: %s", body) } - for _, forbidden := range []string{`"manifest_schema"`, `"manifest_document"`, `"catalog_evidence"`, `"diagnostics"`} { - if bytes.Contains(body, []byte(forbidden)) { - t.Fatalf("new state contains incompatible key %s", forbidden) + for _, required := range []string{`"catalog_evidence"`, `"app_present"`, `"app_bindings"`} { + if !bytes.Contains(body, []byte(required)) { + t.Fatalf("v3 ChatGPT state omitted %s: %s", required, body) } } } +func TestStoreReadsLegacyV2LosslesslyWithoutMutatingUntilSave(t *testing.T) { + t.Parallel() + store := Store{Path: filepath.Join(t.TempDir(), "state-v2.json")} + legacy := domain.StateFileV2{SchemaVersion: domain.LegacyStateSchemaVersion, Installations: []domain.Installation{ + validInstallation("00000000-0000-4000-8000-000000000001", "src_one", "demo-000000000001"), + }} + body, err := json.MarshalIndent(legacy, "", " ") + if err != nil { + t.Fatal(err) + } + body = append(body, '\n') + if err := os.WriteFile(store.Path, body, 0o600); err != nil { + t.Fatal(err) + } + loaded, err := store.Load() + if err != nil { + t.Fatal(err) + } + if loaded.SchemaVersion != domain.StateSchemaVersion || len(loaded.Installations) != 1 || loaded.Installations[0].Source.TreeDigest != "sha256:tree" { + t.Fatalf("legacy state migration = %+v", loaded) + } + afterRead, err := os.ReadFile(store.Path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(afterRead, body) { + t.Fatal("read-only legacy state load rewrote the state file") + } + if err := store.Save(loaded); err != nil { + t.Fatal(err) + } + afterSave, err := os.ReadFile(store.Path) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(afterSave, []byte(`"schema_version": 3`)) { + t.Fatalf("first explicit save did not persist state v3: %s", afterSave) + } +} + +func TestStoreRejectsV3FieldsDisguisedAsLegacyV2(t *testing.T) { + t.Parallel() + state := domain.StateFileV2{SchemaVersion: domain.StateSchemaVersion, Installations: []domain.Installation{validChatGPTInstallation()}} + body, err := json.Marshal(state) + if err != nil { + t.Fatal(err) + } + body = bytes.Replace(body, []byte(`"schema_version":3`), []byte(`"schema_version":2`), 1) + path := filepath.Join(t.TempDir(), "state-v2.json") + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatal(err) + } + if _, err := (Store{Path: path}).Load(); err == nil || (!strings.Contains(err.Error(), "app_present") && !strings.Contains(err.Error(), "catalog_evidence")) { + t.Fatalf("v3 fields under a v2 header were not rejected strictly: %v", err) + } +} + func TestStoreFailsClosedOnFutureOrUnknownState(t *testing.T) { t.Parallel() for name, body := range map[string]string{ - "future": `{"schema_version":3,"installations":[]}`, - "unknown": `{"schema_version":2,"installations":[],"future":true}`, + "future": `{"schema_version":4,"installations":[]}`, + "unknown": `{"schema_version":3,"installations":[],"future":true}`, } { name, body := name, body t.Run(name, func(t *testing.T) { @@ -123,6 +155,17 @@ func TestStoreRejectsDuplicateInstallationIdentity(t *testing.T) { } } +func TestStoreAcceptsOfficialPackageWithoutPortableSchemaURI(t *testing.T) { + t.Parallel() + installation := validInstallation("00000000-0000-4000-8000-000000000001", "src_one", "demo-000000000001") + installation.Package.FormatID = domain.FormatIDOpenAIPlugin + installation.Package.SchemaURI = "" + state := domain.StateFileV2{SchemaVersion: domain.StateSchemaVersion, Installations: []domain.Installation{installation}} + if err := (Store{Path: filepath.Join(t.TempDir(), "state-v2.json")}).Save(state); err != nil { + t.Fatalf("official package state rejected: %v", err) + } +} + func TestStoreRejectsDuplicateReceiptOperationIDsAcrossClients(t *testing.T) { t.Parallel() installations := []domain.Installation{ @@ -194,3 +237,29 @@ func validInstallation(installationID, sourceID, physicalID string) domain.Insta }, } } + +func validChatGPTInstallation() domain.Installation { + installation := validInstallation("00000000-0000-4000-8000-000000000001", "src_chatgpt", "demo-chatgpt") + installation.Package.Inventory = domain.ComponentInventory{ + MCPPresent: true, MCPEnabled: true, MCPServers: []string{"demo"}, + AppPresent: true, AppBindings: []string{"demo"}, + } + for key, client := range installation.Clients { + client.ClientID = "chatgpt" + client.PackageRevision = &domain.ClientPackageRevision{ + Version: "1.0.0", ResolvedRevision: "abc123", TreeDigest: "sha256:tree", ManifestDigest: "sha256:manifest", + CatalogEvidence: &domain.CatalogEvidence{ + SchemaVersion: 2, CatalogVersion: "0.2.0", Repository: "777genius/universal-agent-plugins", + Revision: strings.Repeat("a", 40), Digest: "sha256:catalog", MinimumCLIVersion: "0.1.6", + Compatibility: map[string]domain.CatalogCompatibility{"chatgpt": { + Package: "projected", AppBinding: &domain.CatalogAppBinding{ + AppKey: "demo", ID: "connector_demo", MCPServer: "demo", MCPURL: "https://example.test/mcp", + RuntimeEvidence: "tests/e2e/results/chatgpt-demo.json", RuntimeEvidenceRevision: strings.Repeat("b", 40), + }, + }}, + }, + } + installation.Clients[key] = client + } + return installation +} diff --git a/install/integrationctl/agentplugins/domain/catalog.go b/install/integrationctl/agentplugins/domain/catalog.go index ca361e6f..82870461 100644 --- a/install/integrationctl/agentplugins/domain/catalog.go +++ b/install/integrationctl/agentplugins/domain/catalog.go @@ -1,11 +1,24 @@ package domain -const CatalogSchemaV1 = "https://github.com/777genius/universal-agent-plugins/schemas/catalog-v1.schema.json" +const ( + CatalogSchemaV1 = "https://github.com/777genius/universal-agent-plugins/schemas/catalog-v1.schema.json" + CatalogSchemaV2 = "https://github.com/777genius/universal-agent-plugins/schemas/catalog-v2.schema.json" +) type CatalogCompatibility struct { Package string `json:"package"` Verification string `json:"verification"` Authentication AuthenticationRequirement `json:"authentication"` + AppBinding *CatalogAppBinding `json:"app_binding,omitempty"` +} + +type CatalogAppBinding struct { + AppKey string `json:"app_key"` + ID string `json:"id"` + MCPServer string `json:"mcp_server"` + MCPURL string `json:"mcp_url"` + RuntimeEvidence string `json:"runtime_evidence"` + RuntimeEvidenceRevision string `json:"runtime_evidence_revision"` } type AuthenticationRequirement string diff --git a/install/integrationctl/agentplugins/domain/clients.go b/install/integrationctl/agentplugins/domain/clients.go index cddfe3b9..ab7c9285 100644 --- a/install/integrationctl/agentplugins/domain/clients.go +++ b/install/integrationctl/agentplugins/domain/clients.go @@ -11,6 +11,7 @@ type ComponentKind string const ( ClientCodex ClientID = "codex" + ClientChatGPT ClientID = "chatgpt" ClientCursor ClientID = "cursor" ClientCopilot ClientID = "copilot" ClientVSCode ClientID = "vscode" @@ -43,6 +44,7 @@ const ( ComponentSkill ComponentKind = "skill" ComponentMCPServer ComponentKind = "mcp_server" + ComponentApp ComponentKind = "app" ComponentExtension ComponentKind = "extension" ) @@ -70,6 +72,7 @@ type ClientCapabilities struct { Scopes []InstallScope `json:"scopes"` SkillSupport SupportLevel `json:"skill_support"` MCPTransports map[string]SupportLevel `json:"mcp_transports,omitempty"` + AppSupport SupportLevel `json:"app_support"` ExtensionSupport SupportLevel `json:"extension_support"` } diff --git a/install/integrationctl/agentplugins/domain/state.go b/install/integrationctl/agentplugins/domain/state.go index 58a1b261..a4941741 100644 --- a/install/integrationctl/agentplugins/domain/state.go +++ b/install/integrationctl/agentplugins/domain/state.go @@ -1,6 +1,9 @@ package domain -const StateSchemaVersion = 2 +const ( + LegacyStateSchemaVersion = 2 + StateSchemaVersion = 3 +) type MaterializationState string type ActivationState string @@ -86,10 +89,11 @@ type MutationReceipt struct { // projected into one client. Installation.Package is the latest accepted // revision, while individual clients may temporarily converge one at a time. type ClientPackageRevision struct { - Version string `json:"version,omitempty"` - ResolvedRevision string `json:"resolved_revision,omitempty"` - TreeDigest string `json:"tree_digest"` - ManifestDigest string `json:"manifest_digest"` + Version string `json:"version,omitempty"` + ResolvedRevision string `json:"resolved_revision,omitempty"` + TreeDigest string `json:"tree_digest"` + ManifestDigest string `json:"manifest_digest"` + CatalogEvidence *CatalogEvidence `json:"catalog_evidence,omitempty"` } type ClientBinding struct { diff --git a/install/integrationctl/agentplugins/domain/types.go b/install/integrationctl/agentplugins/domain/types.go index 55cb9281..a1b78041 100644 --- a/install/integrationctl/agentplugins/domain/types.go +++ b/install/integrationctl/agentplugins/domain/types.go @@ -5,6 +5,7 @@ import "encoding/json" const ( LoaderKindAgentPlugins = "agent_plugins" FormatIDAgentPluginsV1 = "agent-plugins/1.0.0" + FormatIDOpenAIPlugin = "openai-agent-plugin/current" LoaderKindLegacy = "legacy" FormatIDLegacyV1 = "plugin-kit-ai/v1" @@ -26,6 +27,7 @@ const ( BoundaryPlugin FailureBoundary = "plugin" BoundaryMCP FailureBoundary = "mcp" BoundaryMCPServer FailureBoundary = "mcp_server" + BoundaryApp FailureBoundary = "app" BoundarySkill FailureBoundary = "skill" BoundaryExtension FailureBoundary = "extension" ) @@ -101,6 +103,27 @@ type MCPComponent struct { InvalidServer map[string]Diagnostic `json:"invalid_servers,omitempty"` } +// AppBinding references a connection that was registered outside the package. +// The CLI validates and projects this reference but never claims ownership of +// the remote connection. +type AppBinding struct { + Alias string `json:"alias"` + ID string `json:"id"` + Optional bool `json:"optional,omitempty"` + Required bool `json:"required,omitempty"` + Raw json.RawMessage `json:"-"` +} + +// AppComponent is the typed, lossless representation of the official root +// .app.json compatibility file. +type AppComponent struct { + Present bool `json:"present"` + Declared bool `json:"declared"` + Enabled bool `json:"enabled"` + Raw json.RawMessage `json:"-"` + Bindings map[string]AppBinding `json:"bindings,omitempty"` +} + type Skill struct { Name string `json:"name"` Description string `json:"description"` @@ -117,6 +140,8 @@ type ComponentInventory struct { MCPEnabled bool `json:"mcp_enabled"` MCPServers []string `json:"mcp_servers,omitempty"` InvalidMCPServer []string `json:"invalid_mcp_servers,omitempty"` + AppPresent bool `json:"app_present,omitempty"` + AppBindings []string `json:"app_bindings,omitempty"` Skills []string `json:"skills,omitempty"` InvalidSkills []string `json:"invalid_skills,omitempty"` InvalidSkillsRoot bool `json:"invalid_skills_root,omitempty"` @@ -131,6 +156,7 @@ type PackageEnvelope struct { ManifestSchema SchemaIdentity `json:"manifest_schema"` Manifest PluginManifest `json:"manifest"` MCP MCPComponent `json:"mcp"` + App AppComponent `json:"app"` Skills map[string]Skill `json:"skills,omitempty"` Inventory ComponentInventory `json:"inventory"` Diagnostics []Diagnostic `json:"diagnostics,omitempty"` diff --git a/install/integrationctl/agentplugins/planner/planner.go b/install/integrationctl/agentplugins/planner/planner.go index b762a1ed..8ebbabd1 100644 --- a/install/integrationctl/agentplugins/planner/planner.go +++ b/install/integrationctl/agentplugins/planner/planner.go @@ -47,7 +47,7 @@ func (planner Planner) Plan( Verification: domain.VerificationPackageValid, PhysicalArtifactID: physicalArtifactID, } - if client.Status != domain.DetectionDetected { + if client.Status != domain.DetectionDetected && client.ClientID != domain.ClientChatGPT { plan.Status = domain.PlanUnsupported plan.Activation = domain.ActivationFailed plan.Warnings = append(plan.Warnings, "client_not_detected") @@ -73,11 +73,24 @@ func (planner Planner) Plan( plan.Diagnostics = append(plan.Diagnostics, diagnostic) plan.Warnings = appendUnique(plan.Warnings, diagnostic.Code) if diagnostic.Severity == domain.SeverityError { - if diagnostic.Boundary == domain.BoundaryMCP || diagnostic.Boundary == domain.BoundaryMCPServer || diagnostic.Boundary == domain.BoundarySkill || diagnostic.Boundary == domain.BoundaryExtension { + if diagnostic.Boundary == domain.BoundaryMCP || diagnostic.Boundary == domain.BoundaryMCPServer || diagnostic.Boundary == domain.BoundarySkill || diagnostic.Boundary == domain.BoundaryExtension || + (diagnostic.Boundary == domain.BoundaryApp && client.ClientID == domain.ClientChatGPT) { hasComponentErrors = true } } } + missingChatGPTApps := missingChatGPTAppBindings(envelope) + if client.ClientID == domain.ClientChatGPT && + ((!envelope.App.Enabled && (len(envelope.MCP.Servers) > 0 || envelope.App.Present || envelope.App.Declared)) || len(missingChatGPTApps) > 0) { + plan.Status = domain.PlanUnsupported + plan.Activation = domain.ActivationFailed + plan.Warnings = appendUnique(plan.Warnings, "chatgpt_app_binding_required") + action := "register every remote MCP connection in ChatGPT Developer Mode and provide a valid root .app.json mapping" + if len(missingChatGPTApps) > 0 { + action += " for: " + strings.Join(missingChatGPTApps, ", ") + } + plan.UserActions = append(plan.UserActions, action) + } if !hasComponents(plan.Components) && hasComponentErrors { plan.Status = domain.PlanUnsupported plan.Activation = domain.ActivationFailed @@ -102,7 +115,13 @@ func (planner Planner) Plan( switch client.ClientID { case domain.ClientCodex: - plan.UserActions = append(plan.UserActions, "finish installation in Codex or ChatGPT Plugins, then start a new session") + plan.UserActions = append(plan.UserActions, "finish installation in Codex Plugins, then start a new session") + case domain.ClientChatGPT: + if hasSupportedKind(plan.Components, domain.ComponentApp) { + plan.UserActions = append(plan.UserActions, "install the prepared plugin from ChatGPT Plugins, verify its registered app connection, then start a new chat") + } else { + plan.UserActions = append(plan.UserActions, "install the prepared skills-only plugin from ChatGPT Plugins, then start a new chat") + } case domain.ClientCursor: plan.UserActions = append(plan.UserActions, "reload Cursor, then verify the plugin appears before using its components") case domain.ClientCopilot: @@ -238,31 +257,37 @@ func Capabilities(clientID domain.ClientID) (domain.ClientCapabilities, bool) { return domain.ClientCapabilities{ ClientID: clientID, PackageMode: domain.PackageProjection, ActivationMode: domain.ActivationByUser, Scopes: []domain.InstallScope{domain.ScopeUser}, SkillSupport: domain.SupportProjected, - MCPTransports: mapSupport(allMCP, domain.SupportProjected), ExtensionSupport: domain.SupportUnsupported, + MCPTransports: mapSupport(allMCP, domain.SupportProjected), AppSupport: domain.SupportUnsupported, ExtensionSupport: domain.SupportUnsupported, + }, true + case domain.ClientChatGPT: + return domain.ClientCapabilities{ + ClientID: clientID, PackageMode: domain.PackageProjection, ActivationMode: domain.ActivationByUser, + Scopes: []domain.InstallScope{domain.ScopeUser}, SkillSupport: domain.SupportProjected, + MCPTransports: mapSupport(allMCP, domain.SupportUnsupported), AppSupport: domain.SupportProjected, ExtensionSupport: domain.SupportUnsupported, }, true case domain.ClientCursor: return domain.ClientCapabilities{ ClientID: clientID, PackageMode: domain.PackageNative, ActivationMode: domain.ActivationByUser, Scopes: []domain.InstallScope{domain.ScopeUser}, SkillSupport: domain.SupportNative, - MCPTransports: allMCP, ExtensionSupport: domain.SupportNative, + MCPTransports: allMCP, AppSupport: domain.SupportUnsupported, ExtensionSupport: domain.SupportNative, }, true case domain.ClientCopilot: return domain.ClientCapabilities{ ClientID: clientID, PackageMode: domain.PackageNative, ActivationMode: domain.ActivationByUser, Scopes: []domain.InstallScope{domain.ScopeUser}, SkillSupport: domain.SupportNative, - MCPTransports: allMCP, ExtensionSupport: domain.SupportNative, + MCPTransports: allMCP, AppSupport: domain.SupportUnsupported, ExtensionSupport: domain.SupportNative, }, true case domain.ClientVSCode: return domain.ClientCapabilities{ ClientID: clientID, PackageMode: domain.PackagePrepared, ActivationMode: domain.ActivationByUser, Scopes: []domain.InstallScope{domain.ScopeUser}, SkillSupport: domain.SupportPrepared, - MCPTransports: mapSupport(allMCP, domain.SupportPrepared), ExtensionSupport: domain.SupportPrepared, + MCPTransports: mapSupport(allMCP, domain.SupportPrepared), AppSupport: domain.SupportUnsupported, ExtensionSupport: domain.SupportPrepared, }, true case domain.ClientKiro: return domain.ClientCapabilities{ ClientID: clientID, PackageMode: domain.PackageNative, ActivationMode: domain.ActivationByUser, Scopes: []domain.InstallScope{domain.ScopeUser}, SkillSupport: domain.SupportNative, - MCPTransports: allMCP, ExtensionSupport: domain.SupportUnsupported, + MCPTransports: allMCP, AppSupport: domain.SupportUnsupported, ExtensionSupport: domain.SupportUnsupported, }, true default: return domain.ClientCapabilities{}, false @@ -300,7 +325,7 @@ func (planner Planner) targetRoot(client domain.DetectedClient, mode domain.Pack } func componentDecisions(envelope domain.PackageEnvelope, capabilities domain.ClientCapabilities) []domain.ComponentDecision { - decisions := make([]domain.ComponentDecision, 0, len(envelope.Skills)+len(envelope.MCP.Servers)+len(envelope.Manifest.Extensions)) + decisions := make([]domain.ComponentDecision, 0, len(envelope.Skills)+len(envelope.MCP.Servers)+len(envelope.App.Bindings)+len(envelope.Manifest.Extensions)) skillNames := sortedKeys(envelope.Skills) for _, name := range skillNames { decisions = append(decisions, decision(domain.ComponentSkill, name, capabilities.SkillSupport)) @@ -312,8 +337,17 @@ func componentDecisions(envelope domain.PackageEnvelope, capabilities domain.Cli if !ok { support = domain.SupportUnsupported } + if capabilities.ClientID == domain.ClientChatGPT && envelope.App.Enabled { + if _, mapped := envelope.App.Bindings[name]; mapped { + support = domain.SupportProjected + } + } decisions = append(decisions, decision(domain.ComponentMCPServer, name, support)) } + appNames := sortedKeys(envelope.App.Bindings) + for _, name := range appNames { + decisions = append(decisions, decision(domain.ComponentApp, name, capabilities.AppSupport)) + } extensionNames := sortedKeys(envelope.Manifest.Extensions) for _, name := range extensionNames { decisions = append(decisions, decision(domain.ComponentExtension, name, capabilities.ExtensionSupport)) @@ -322,6 +356,9 @@ func componentDecisions(envelope domain.PackageEnvelope, capabilities domain.Cli } func decision(kind domain.ComponentKind, name string, support domain.SupportLevel) domain.ComponentDecision { + if support == "" { + support = domain.SupportUnsupported + } value := domain.ComponentDecision{Kind: kind, Name: name, Support: support} if support == domain.SupportUnsupported { value.Reason = "component_not_supported_by_client" @@ -389,3 +426,23 @@ func hasSupportedComponent(decisions []domain.ComponentDecision) bool { } return false } + +func hasSupportedKind(decisions []domain.ComponentDecision, kind domain.ComponentKind) bool { + for _, item := range decisions { + if item.Kind == kind && item.Support != domain.SupportUnsupported { + return true + } + } + return false +} + +func missingChatGPTAppBindings(envelope domain.PackageEnvelope) []string { + missing := make([]string, 0) + for name := range envelope.MCP.Servers { + if _, ok := envelope.App.Bindings[name]; !ok { + missing = append(missing, name) + } + } + sort.Strings(missing) + return missing +} diff --git a/install/integrationctl/agentplugins/planner/planner_test.go b/install/integrationctl/agentplugins/planner/planner_test.go index d4d42eb1..112a1f10 100644 --- a/install/integrationctl/agentplugins/planner/planner_test.go +++ b/install/integrationctl/agentplugins/planner/planner_test.go @@ -179,6 +179,99 @@ func TestPlannerFailsClosedForUndetectedClientAndUnsupportedScope(t *testing.T) } } +func TestChatGPTRemoteTargetSupportsSkillsWithoutDesktopDetection(t *testing.T) { + t.Parallel() + envelope := domain.PackageEnvelope{ + Manifest: domain.PluginManifest{Name: "skills-only"}, + Skills: map[string]domain.Skill{"docs": {Name: "docs"}}, + } + client := domain.DetectedClient{ClientID: domain.ClientChatGPT, Status: domain.DetectionNotDetected} + plan, err := (Planner{ManagedRoot: t.TempDir()}).Plan(context.Background(), envelope, client, domain.ScopeUser, "skills-only-0123456789ab") + if err != nil { + t.Fatal(err) + } + if plan.Status != domain.PlanManualActivationRequired || supportOf(plan, domain.ComponentSkill, "docs") != domain.SupportProjected || contains(plan.Warnings, "client_not_detected") { + t.Fatalf("ChatGPT skills plan = %+v", plan) + } + if !containsText(plan.UserActions, "skills-only") || containsText(plan.UserActions, ".app.json") || containsText(plan.UserActions, "registered app") { + t.Fatalf("ChatGPT skills actions = %+v", plan.UserActions) + } +} + +func TestOnlyChatGPTProjectsRegisteredAppBindings(t *testing.T) { + t.Parallel() + for _, client := range []domain.ClientID{domain.ClientCodex, domain.ClientChatGPT, domain.ClientCursor, domain.ClientCopilot, domain.ClientVSCode, domain.ClientKiro} { + capabilities, ok := Capabilities(client) + if !ok { + t.Fatalf("missing capabilities for %s", client) + } + want := domain.SupportUnsupported + if client == domain.ClientChatGPT { + want = domain.SupportProjected + } + if capabilities.AppSupport != want { + t.Fatalf("%s app support = %q, want %q", client, capabilities.AppSupport, want) + } + } +} + +func TestChatGPTMCPFailsClosedWithoutValidAppBinding(t *testing.T) { + t.Parallel() + envelope := domain.PackageEnvelope{ + Manifest: domain.PluginManifest{Name: "remote-mcp"}, + MCP: domain.MCPComponent{Present: true, Enabled: true, Servers: map[string]domain.MCPServer{ + "docs": {Name: "docs", Type: "streamable-http"}, + }}, + } + plan, err := (Planner{ManagedRoot: t.TempDir()}).Plan(context.Background(), envelope, domain.DetectedClient{ClientID: domain.ClientChatGPT}, domain.ScopeUser, "remote-mcp-0123456789ab") + if err != nil { + t.Fatal(err) + } + if plan.Status != domain.PlanUnsupported || !contains(plan.Warnings, "chatgpt_app_binding_required") { + t.Fatalf("ChatGPT missing app plan = %+v", plan) + } +} + +func TestChatGPTMCPFailsClosedWhenAppAliasDoesNotMapServer(t *testing.T) { + t.Parallel() + envelope := domain.PackageEnvelope{ + Manifest: domain.PluginManifest{Name: "remote-mcp"}, + MCP: domain.MCPComponent{Present: true, Enabled: true, Servers: map[string]domain.MCPServer{ + "docs": {Name: "docs", Type: "streamable-http"}, + }}, + App: domain.AppComponent{Present: true, Declared: true, Enabled: true, Bindings: map[string]domain.AppBinding{ + "different": {Alias: "different", ID: "plugin_asdk_app_different_123"}, + }}, + } + plan, err := (Planner{ManagedRoot: t.TempDir()}).Plan(context.Background(), envelope, domain.DetectedClient{ClientID: domain.ClientChatGPT}, domain.ScopeUser, "remote-mcp-0123456789ab") + if err != nil { + t.Fatal(err) + } + if plan.Status != domain.PlanUnsupported || !contains(plan.Warnings, "chatgpt_app_binding_required") || supportOf(plan, domain.ComponentMCPServer, "docs") != domain.SupportUnsupported || !containsText(plan.UserActions, "docs") { + t.Fatalf("ChatGPT mismatched app plan = %+v", plan) + } +} + +func TestChatGPTProjectsMCPThroughRegisteredAppBinding(t *testing.T) { + t.Parallel() + envelope := domain.PackageEnvelope{ + Manifest: domain.PluginManifest{Name: "remote-mcp"}, + MCP: domain.MCPComponent{Present: true, Enabled: true, Servers: map[string]domain.MCPServer{ + "docs": {Name: "docs", Type: "streamable-http"}, + }}, + App: domain.AppComponent{Present: true, Declared: true, Enabled: true, Bindings: map[string]domain.AppBinding{ + "docs": {Alias: "docs", ID: "asdk_app_docs_123"}, + }}, + } + plan, err := (Planner{ManagedRoot: t.TempDir()}).Plan(context.Background(), envelope, domain.DetectedClient{ClientID: domain.ClientChatGPT}, domain.ScopeUser, "remote-mcp-0123456789ab") + if err != nil { + t.Fatal(err) + } + if plan.Status != domain.PlanManualActivationRequired || supportOf(plan, domain.ComponentMCPServer, "docs") != domain.SupportProjected || supportOf(plan, domain.ComponentApp, "docs") != domain.SupportProjected { + t.Fatalf("ChatGPT app plan = %+v", plan) + } +} + func TestPlannerRejectsMetadataOnlyProjectionWhenAllDeclaredComponentsAreInvalid(t *testing.T) { t.Parallel() for name, diagnostic := range map[string]domain.Diagnostic{ @@ -297,3 +390,12 @@ func contains(values []string, want string) bool { } return false } + +func containsText(values []string, fragment string) bool { + for _, value := range values { + if strings.Contains(value, fragment) { + return true + } + } + return false +} diff --git a/install/integrationctl/agentplugins/providers/activator.go b/install/integrationctl/agentplugins/providers/activator.go index 7ff8e81c..1db0e6d8 100644 --- a/install/integrationctl/agentplugins/providers/activator.go +++ b/install/integrationctl/agentplugins/providers/activator.go @@ -33,7 +33,9 @@ func (activator Activator) Deactivate(ctx context.Context, request domain.Deacti case domain.ClientCursor: return outcome, nil case domain.ClientCodex: - return requireExternalUninstall(outcome, request.ExternalUninstalled, "uninstall the plugin in Codex or ChatGPT, then rerun remove with `--external-uninstalled` (also use the flag if it was never activated)"), nil + return requireExternalUninstall(outcome, request.ExternalUninstalled, "uninstall the plugin in Codex, then rerun remove with `--external-uninstalled` (also use the flag if it was never activated)"), nil + case domain.ClientChatGPT: + return requireExternalUninstall(outcome, request.ExternalUninstalled, "uninstall the plugin in ChatGPT Plugins, then rerun remove with `--external-uninstalled` (also use the flag if it was never activated)"), nil case domain.ClientKiro: return requireExternalUninstall(outcome, request.ExternalUninstalled, "remove the custom Power in Kiro, then rerun remove with `--external-uninstalled` (also use the flag if it was never imported)"), nil case domain.ClientCopilot, domain.ClientVSCode: @@ -126,8 +128,8 @@ func (activator Activator) Activate(ctx context.Context, request domain.Activati case domain.ClientCodex: if strings.TrimSpace(request.BackendExecutable) == "" || activator.Runner == nil { outcome.Activation = domain.ActivationManual - outcome.UserActions = append(outcome.UserActions, "install the prepared plugin in the ChatGPT/Codex app, then verify it appears in Plugins > Personal") - outcome.LocalActions = append(outcome.LocalActions, fmt.Sprintf("in the ChatGPT/Codex app, install %s from %s, then verify it appears in Plugins > Personal", request.DeclaredName, request.Delivery.ActivePath)) + outcome.UserActions = append(outcome.UserActions, "install the prepared plugin in Codex Plugins, then verify it appears in Plugins > Personal") + outcome.LocalActions = append(outcome.LocalActions, fmt.Sprintf("in Codex, install %s from %s, then verify it appears in Plugins > Personal", request.DeclaredName, request.Delivery.ActivePath)) return outcome, nil } if request.VerifyOnly { @@ -150,6 +152,16 @@ func (activator Activator) Activate(ctx context.Context, request domain.Activati outcome.Activation = domain.ActivationActive outcome.Verification = domain.VerificationInstalled return outcome, nil + case domain.ClientChatGPT: + outcome.Activation = domain.ActivationManual + if componentKindPresent(request.Plan.Components, domain.ComponentApp) { + outcome.UserActions = append(outcome.UserActions, "in ChatGPT Developer Mode, verify the registered connection referenced by .app.json, install the plugin from Plugins, then confirm it is enabled in a new chat") + outcome.LocalActions = append(outcome.LocalActions, fmt.Sprintf("open ChatGPT Plugins and install %s from the prepared marketplace at %s; verify every .app.json connection before confirming activation", request.DeclaredName, request.Delivery.ActivePath)) + } else { + outcome.UserActions = append(outcome.UserActions, "install the prepared skills-only plugin from ChatGPT Plugins, then confirm it is enabled in a new chat") + outcome.LocalActions = append(outcome.LocalActions, fmt.Sprintf("open ChatGPT Plugins and install %s from the prepared marketplace at %s, then confirm it is enabled in a new chat", request.DeclaredName, request.Delivery.ActivePath)) + } + return outcome, nil case domain.ClientKiro: if !mcpOnly(request.Plan.Components) { outcome.Activation = domain.ActivationManual @@ -412,6 +424,15 @@ func mcpOnly(components []domain.ComponentDecision) bool { return found } +func componentKindPresent(components []domain.ComponentDecision, kind domain.ComponentKind) bool { + for _, component := range components { + if component.Kind == kind && component.Support != domain.SupportUnsupported { + return true + } + } + return false +} + func isKiroCLI(executable string) bool { base := strings.ToLower(filepath.Base(strings.TrimSpace(executable))) return base == "kiro-cli" || base == "kiro-cli.exe" || base == "kiro" || base == "kiro.exe" diff --git a/install/integrationctl/agentplugins/providers/activator_test.go b/install/integrationctl/agentplugins/providers/activator_test.go index 6481e727..d0321d6e 100644 --- a/install/integrationctl/agentplugins/providers/activator_test.go +++ b/install/integrationctl/agentplugins/providers/activator_test.go @@ -732,6 +732,50 @@ func TestDeactivatorPreservesManualClientArtifactsUntilAcknowledged(t *testing.T } } +func TestChatGPTActivationIsManualAndNeverUsesCodexRunner(t *testing.T) { + t.Parallel() + request := activationRequest(t, domain.ClientChatGPT) + request.Plan.Components = []domain.ComponentDecision{{Kind: domain.ComponentApp, Name: "demo", Support: domain.SupportProjected}} + request.BackendExecutable = "/test/bin/codex" + runner := &recordingRunner{} + outcome, err := (Activator{Runner: runner}).Activate(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if outcome.Activation != domain.ActivationManual || outcome.Verification != domain.VerificationPackageValid || len(runner.commands) != 0 { + t.Fatalf("ChatGPT activation = %+v commands=%+v", outcome, runner.commands) + } + if len(outcome.UserActions) != 1 || !strings.Contains(outcome.UserActions[0], "Developer Mode") || !strings.Contains(outcome.UserActions[0], ".app.json") { + t.Fatalf("ChatGPT actions = %+v", outcome.UserActions) + } +} + +func TestChatGPTSkillsOnlyActivationDoesNotMentionAppRegistration(t *testing.T) { + t.Parallel() + request := activationRequest(t, domain.ClientChatGPT) + request.Plan.Components = []domain.ComponentDecision{{Kind: domain.ComponentSkill, Name: "docs", Support: domain.SupportProjected}} + outcome, err := (Activator{}).Activate(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if outcome.Activation != domain.ActivationManual || len(outcome.UserActions) != 1 || !strings.Contains(outcome.UserActions[0], "skills-only") || strings.Contains(outcome.UserActions[0], ".app.json") || strings.Contains(outcome.UserActions[0], "Developer Mode") { + t.Fatalf("ChatGPT skills-only activation = %+v", outcome) + } +} + +func TestChatGPTActivationCanOnlyCompleteByExplicitAttestation(t *testing.T) { + t.Parallel() + request := activationRequest(t, domain.ClientChatGPT) + request.ActivationComplete = true + outcome, err := (Activator{}).Activate(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if outcome.Activation != domain.ActivationActive || outcome.Verification != domain.VerificationInstalled || !outcome.ActivationAttested { + t.Fatalf("ChatGPT attestation = %+v", outcome) + } +} + func activationRequest(t *testing.T, client domain.ClientID) domain.ActivationRequest { t.Helper() base := filepath.Join(t.TempDir(), "managed") diff --git a/install/integrationctl/agentplugins/providers/stager.go b/install/integrationctl/agentplugins/providers/stager.go index eb6e3ff1..b14a25bc 100644 --- a/install/integrationctl/agentplugins/providers/stager.go +++ b/install/integrationctl/agentplugins/providers/stager.go @@ -137,12 +137,21 @@ func (stager Stager) Stage( if err := sanitizePackage(stagingPath, envelope, plan); err != nil { return domain.StagedDelivery{}, err } - if plan.PackageMode == domain.PackageProjection && plan.ClientID == domain.ClientCodex { - if err := projectOpenAI(stagingPath, envelope, plan, hints); err != nil { - return domain.StagedDelivery{}, err + if plan.PackageMode == domain.PackageProjection { + switch plan.ClientID { + case domain.ClientCodex: + if err := projectOpenAI(stagingPath, envelope, plan, hints); err != nil { + return domain.StagedDelivery{}, err + } + case domain.ClientChatGPT: + if err := projectChatGPT(stagingPath, envelope, plan, hints); err != nil { + return domain.StagedDelivery{}, err + } } - if err := projectCodexMarketplace(stagingPath, envelope, plan); err != nil { - return domain.StagedDelivery{}, err + if plan.ClientID == domain.ClientCodex || plan.ClientID == domain.ClientChatGPT { + if err := projectCodexMarketplace(stagingPath, envelope, plan); err != nil { + return domain.StagedDelivery{}, err + } } } if plan.ClientID == domain.ClientCopilot || plan.ClientID == domain.ClientVSCode { @@ -201,9 +210,23 @@ func sanitizePackage(root string, envelope domain.PackageEnvelope, plan domain.D if err := writeSanitizedMCP(root, envelope, plan); err != nil { return err } + if err := writeSanitizedApp(root, envelope, plan); err != nil { + return err + } return writeSanitizedExtensions(root, plan) } +func writeSanitizedApp(root string, envelope domain.PackageEnvelope, plan domain.DeliveryPlan) error { + path := filepath.Join(root, ".app.json") + if plan.ClientID != domain.ClientChatGPT || !envelope.App.Enabled || len(envelope.App.Raw) == 0 { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove unsupported .app.json: %w", err) + } + return nil + } + return atomicfile.Write(path, append([]byte(nil), envelope.App.Raw...), 0o644) +} + func removeInvalidAndUnsupportedSkills(root string, envelope domain.PackageEnvelope, plan domain.DeliveryPlan) error { names := append([]string(nil), envelope.Inventory.InvalidSkills...) for _, component := range plan.Components { @@ -306,7 +329,13 @@ func writeSanitizedExtensions(root string, plan domain.DeliveryPlan) error { } func projectOpenAI(root string, envelope domain.PackageEnvelope, plan domain.DeliveryPlan, hints domain.CompatibilityHints) error { - manifest := map[string]any{"name": envelope.Manifest.Name} + manifest, err := projectedOpenAIManifest(envelope) + if err != nil { + return err + } + delete(manifest, "apps") + delete(manifest, "skills") + delete(manifest, "mcpServers") copyString := func(key, value string) { if strings.TrimSpace(value) != "" { manifest[key] = value @@ -334,7 +363,14 @@ func projectOpenAI(root string, envelope domain.PackageEnvelope, plan domain.Del if err := writeJSON(manifestPath, manifest); err != nil { return fmt.Errorf("write OpenAI compatibility manifest: %w", err) } + return projectOpenAIMCP(root, envelope, serverNames, hints) +} + +func projectOpenAIMCP(root string, envelope domain.PackageEnvelope, serverNames []string, hints domain.CompatibilityHints) error { if len(serverNames) == 0 { + if err := os.Remove(filepath.Join(root, ".mcp.json")); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove empty OpenAI MCP projection: %w", err) + } return nil } servers := map[string]map[string]any{} @@ -364,6 +400,69 @@ func projectOpenAI(root string, envelope domain.PackageEnvelope, plan domain.Del return writeJSON(filepath.Join(root, ".mcp.json"), map[string]any{"mcpServers": servers}) } +func projectChatGPT(root string, envelope domain.PackageEnvelope, plan domain.DeliveryPlan, hints domain.CompatibilityHints) error { + manifest, err := projectedOpenAIManifest(envelope) + if err != nil { + return err + } + serverNames := supportedMCPNames(plan) + if len(serverNames) > 0 { + manifest["mcpServers"] = "./.mcp.json" + } else { + delete(manifest, "mcpServers") + } + if hasSupported(plan, domain.ComponentSkill) { + manifest["skills"] = "./skills/" + } else { + delete(manifest, "skills") + } + if envelope.App.Enabled && hasSupported(plan, domain.ComponentApp) { + manifest["apps"] = "./.app.json" + } else { + delete(manifest, "apps") + } + if err := writeJSON(filepath.Join(root, ".codex-plugin", "plugin.json"), manifest); err != nil { + return fmt.Errorf("write ChatGPT plugin manifest: %w", err) + } + if err := projectOpenAIMCP(root, envelope, serverNames, hints); err != nil { + return err + } + for _, portableManifest := range []string{"plugin.json", "mcp.json"} { + if err := os.Remove(filepath.Join(root, portableManifest)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove portable %s from official ChatGPT projection: %w", portableManifest, err) + } + } + return nil +} + +func projectedOpenAIManifest(envelope domain.PackageEnvelope) (map[string]any, error) { + if envelope.FormatID == domain.FormatIDOpenAIPlugin && len(envelope.Manifest.Raw) > 0 { + var manifest map[string]any + if err := json.Unmarshal(envelope.Manifest.Raw, &manifest); err != nil || manifest == nil { + return nil, fmt.Errorf("decode preserved OpenAI plugin manifest: %w", err) + } + return manifest, nil + } + manifest := map[string]any{"name": envelope.Manifest.Name} + copyString := func(key, value string) { + if strings.TrimSpace(value) != "" { + manifest[key] = value + } + } + copyString("version", envelope.Manifest.Version) + copyString("description", envelope.Manifest.Description) + copyString("homepage", envelope.Manifest.Homepage) + copyString("repository", envelope.Manifest.Repository) + copyString("license", envelope.Manifest.License) + if envelope.Manifest.Author != nil { + manifest["author"] = envelope.Manifest.Author + } + if len(envelope.Manifest.Keywords) > 0 { + manifest["keywords"] = envelope.Manifest.Keywords + } + return manifest, nil +} + func supportedMCPNames(plan domain.DeliveryPlan) []string { var names []string for _, component := range plan.Components { diff --git a/install/integrationctl/agentplugins/providers/stager_test.go b/install/integrationctl/agentplugins/providers/stager_test.go index b58bef93..81bc18d8 100644 --- a/install/integrationctl/agentplugins/providers/stager_test.go +++ b/install/integrationctl/agentplugins/providers/stager_test.go @@ -63,6 +63,49 @@ func TestStagerBuildsOpenAIProjectionWithoutMutatingPortableSnapshot(t *testing. } } +func TestStagerBuildsChatGPTAppProjectionWithBundledMCPParity(t *testing.T) { + t.Parallel() + envelope := stagingEnvelope(t) + app := `{"apps":{"notion":{"id":"asdk_app_notion_123","required":true}}}` + writeTestFile(t, filepath.Join(envelope.SnapshotRoot, ".app.json"), app) + envelope.App = domain.AppComponent{ + Present: true, Declared: true, Enabled: true, Raw: json.RawMessage(app), + Bindings: map[string]domain.AppBinding{"notion": {Alias: "notion", ID: "asdk_app_notion_123", Required: true}}, + } + envelope.Inventory.AppPresent = true + envelope.Inventory.AppBindings = []string{"notion"} + plan := stagingPlan(t, domain.ClientChatGPT, domain.PackageProjection) + plan.Components = append(plan.Components, domain.ComponentDecision{Kind: domain.ComponentApp, Name: "notion", Support: domain.SupportProjected}) + + delivery, err := (Stager{}).Stage(context.Background(), envelope, plan, "operation-chatgpt", domain.CompatibilityHints{ + OpenAIMCPAuth: map[string]domain.OpenAIMCPAuthHint{ + "notion": {OAuthResource: "https://mcp.notion.com"}, + }, + }) + if err != nil { + t.Fatal(err) + } + manifest := readObject(t, filepath.Join(delivery.StagingPath, ".codex-plugin", "plugin.json")) + if manifest["apps"] != "./.app.json" || manifest["mcpServers"] != "./.mcp.json" || manifest["skills"] != "./skills/" { + t.Fatalf("ChatGPT manifest = %+v", manifest) + } + openAIMCP := readObject(t, filepath.Join(delivery.StagingPath, ".mcp.json")) + servers := openAIMCP["mcpServers"].(map[string]any) + notion := servers["notion"].(map[string]any) + if notion["type"] != "http" || notion["oauth_resource"] != "https://mcp.notion.com" { + t.Fatalf("ChatGPT MCP parity projection = %+v", notion) + } + assertMissing(t, filepath.Join(delivery.StagingPath, "plugin.json")) + assertMissing(t, filepath.Join(delivery.StagingPath, "mcp.json")) + body, err := os.ReadFile(filepath.Join(delivery.StagingPath, ".app.json")) + if err != nil || string(body) != app { + t.Fatalf("lossless app projection = %q, %v", body, err) + } + if source, err := os.ReadFile(filepath.Join(envelope.SnapshotRoot, "mcp.json")); err != nil || len(source) == 0 { + t.Fatal("portable source MCP was mutated") + } +} + func TestStagerBuildsManagedCopilotMarketplaceForCopilotAndVSCode(t *testing.T) { t.Parallel() for _, client := range []domain.ClientID{domain.ClientCopilot, domain.ClientVSCode} { @@ -250,7 +293,7 @@ func stagingPlan(t *testing.T, client domain.ClientID, mode domain.PackageMode) active := filepath.Join(target, "demo-0123456789ab") status := domain.PlanReady activation := domain.ActivationActive - if client == domain.ClientCodex || client == domain.ClientKiro { + if client == domain.ClientCodex || client == domain.ClientChatGPT || client == domain.ClientKiro { status = domain.PlanManualActivationRequired activation = domain.ActivationManual } diff --git a/install/integrationctl/agentplugins/usecase/legacy_remove.go b/install/integrationctl/agentplugins/usecase/legacy_remove.go index fe52b0b6..17b84363 100644 --- a/install/integrationctl/agentplugins/usecase/legacy_remove.go +++ b/install/integrationctl/agentplugins/usecase/legacy_remove.go @@ -123,7 +123,7 @@ func (service Service) RemoveLegacy(ctx context.Context, input LegacyRemoveInput installation.UpdatedAt = timestamp state.Installations[installationIndex] = installation if err := service.StateStore.Save(state); err != nil { - return result, fmt.Errorf("reconcile State v2 after legacy removal: %w", err) + return result, fmt.Errorf("reconcile Agent Plugins state after legacy removal: %w", err) } result.Mutated = true return result, nil diff --git a/install/integrationctl/agentplugins/usecase/service.go b/install/integrationctl/agentplugins/usecase/service.go index 4e94238e..2df16fd5 100644 --- a/install/integrationctl/agentplugins/usecase/service.go +++ b/install/integrationctl/agentplugins/usecase/service.go @@ -120,6 +120,10 @@ func (service Service) apply(ctx context.Context, input AddInput, replace bool) } result := AddResult{InstallationID: installationID, Plan: plan} if plan.Status == domain.PlanUnsupported { + action := strings.Join(plan.UserActions, "; ") + if action != "" { + return result, fmt.Errorf("delivery plan for %s is unsupported. Next: %s", plan.ClientID, action) + } return result, fmt.Errorf("delivery plan for %s is unsupported", plan.ClientID) } if err := rejectNativeNameCollision(state, installationID, input.Envelope.Manifest.Name, input.Client.ClientID); err != nil { @@ -591,6 +595,24 @@ func upsertPreparedInstallation( return state, len(state.Installations) - 1 } +func cloneCatalogEvidence(source *domain.CatalogEvidence) *domain.CatalogEvidence { + if source == nil { + return nil + } + result := *source + if len(source.Compatibility) > 0 { + result.Compatibility = make(map[string]domain.CatalogCompatibility, len(source.Compatibility)) + for client, compatibility := range source.Compatibility { + if compatibility.AppBinding != nil { + binding := *compatibility.AppBinding + compatibility.AppBinding = &binding + } + result.Compatibility[client] = compatibility + } + } + return &result +} + func validatePackageTransition(installation domain.Installation, envelope domain.PackageEnvelope) error { incomingVersion := envelope.Manifest.Version currentVersion := installation.Package.Version @@ -622,11 +644,13 @@ func packageRevisionFromEnvelope(envelope domain.PackageEnvelope) *domain.Client return &domain.ClientPackageRevision{ Version: envelope.Manifest.Version, ResolvedRevision: envelope.Source.ResolvedRevision, TreeDigest: envelope.TreeDigest, ManifestDigest: envelope.ManifestDigest, + CatalogEvidence: cloneCatalogEvidence(envelope.CatalogEvidence), } } func packageRevisionMatches(revision *domain.ClientPackageRevision, envelope domain.PackageEnvelope) bool { - return revision != nil && revision.TreeDigest == envelope.TreeDigest && revision.ManifestDigest == envelope.ManifestDigest + return revision != nil && revision.TreeDigest == envelope.TreeDigest && revision.ManifestDigest == envelope.ManifestDigest && + reflect.DeepEqual(revision.CatalogEvidence, envelope.CatalogEvidence) } func findSourceInstallation(state domain.StateFileV2, sourceBindingID string) (int, bool) { diff --git a/install/integrationctl/agentplugins/usecase/service_test.go b/install/integrationctl/agentplugins/usecase/service_test.go index 4223e8a3..2694f9d5 100644 --- a/install/integrationctl/agentplugins/usecase/service_test.go +++ b/install/integrationctl/agentplugins/usecase/service_test.go @@ -83,11 +83,13 @@ func TestAddCommitsCursorPackageReceiptAndLeavesDiscoveryManual(t *testing.T) { t.Fatalf("installations = %+v", state.Installations) } packageState := state.Installations[0].Package + clientState := onlyBinding(state.Installations[0]) if packageState.SchemaURI != domain.PluginSchemaV1 || packageState.ManifestDigest != input.Envelope.ManifestDigest || + clientState.PackageRevision == nil || clientState.PackageRevision.CatalogEvidence == nil || + clientState.PackageRevision.CatalogEvidence.Compatibility["cursor"].Verification != "tested" || !strings.Contains(string(input.Envelope.Manifest.Raw), `"future"`) || input.Envelope.CatalogEvidence == nil || len(input.Envelope.Diagnostics) != 1 { - t.Fatalf("in-memory evidence or compatible package binding was lost: envelope=%+v package=%+v", input.Envelope, packageState) + t.Fatalf("in-memory evidence or client revision binding was lost: envelope=%+v package=%+v client=%+v", input.Envelope, packageState, clientState) } - clientState := onlyBinding(state.Installations[0]) if clientState.Materialization != domain.MaterializationMaterialized || clientState.Activation != domain.ActivationManual || clientState.Verification != domain.VerificationPackageValid { t.Fatalf("client state = %+v", clientState) } @@ -1080,17 +1082,28 @@ func TestCopilotAndVSCodeShareOneNativeBackend(t *testing.T) { func TestMultiClientUpdateConvergesEachClientRevision(t *testing.T) { t.Parallel() service, store, cursor := serviceFixture(t) + evidence := func(digest string) *domain.CatalogEvidence { + return &domain.CatalogEvidence{ + SchemaVersion: 2, CatalogVersion: "0.2.0", Repository: "example/catalog", + Revision: strings.Repeat("a", 40), Digest: digest, MinimumCLIVersion: "0.1.6", + Compatibility: map[string]domain.CatalogCompatibility{ + "cursor": {Package: "native"}, "codex": {Package: "projected"}, + }, + } + } codex := domain.DetectedClient{ ClientID: domain.ClientCodex, Status: domain.DetectionDetected, ConfigRoot: filepath.Join(t.TempDir(), ".codex"), } firstCursor := addInput(t, cursor, "https://example.com/shared") + firstCursor.Envelope.CatalogEvidence = evidence("sha256:catalog-v1") firstCursor.Confirmed = true firstCursor.OperationID = "operation-cursor-v1" if _, err := service.Add(context.Background(), firstCursor); err != nil { t.Fatal(err) } firstCodex := addInput(t, codex, "https://example.com/shared") + firstCodex.Envelope.CatalogEvidence = evidence("sha256:catalog-v1") firstCodex.Confirmed = true firstCodex.OperationID = "operation-codex-v1" if _, err := service.Add(context.Background(), firstCodex); err != nil { @@ -1099,6 +1112,7 @@ func TestMultiClientUpdateConvergesEachClientRevision(t *testing.T) { updateCursor := addInput(t, cursor, "https://example.com/shared") setEnvelopeVersion(t, &updateCursor.Envelope, "2.0.0", "sha256:tree-v2", "sha256:manifest-v2") + updateCursor.Envelope.CatalogEvidence = evidence("sha256:catalog-v2") updateCursor.Confirmed = true updateCursor.OperationID = "operation-cursor-v2" if result, err := service.Update(context.Background(), updateCursor); err != nil || result.NoChange { @@ -1109,12 +1123,17 @@ func TestMultiClientUpdateConvergesEachClientRevision(t *testing.T) { if err != nil { t.Fatal(err) } - if revisionForClient(t, state.Installations[0], domain.ClientCodex).Version != "1.0.0" { + if revision := revisionForClient(t, state.Installations[0], domain.ClientCodex); revision.Version != "1.0.0" || + revision.CatalogEvidence == nil || revision.CatalogEvidence.Digest != "sha256:catalog-v1" { t.Fatalf("Codex revision advanced before its update: %+v", state.Installations[0].Clients) } + if revision := revisionForClient(t, state.Installations[0], domain.ClientCursor); revision.CatalogEvidence == nil || revision.CatalogEvidence.Digest != "sha256:catalog-v2" { + t.Fatalf("Cursor catalog evidence did not advance with its revision: %+v", revision) + } updateCodex := addInput(t, codex, "https://example.com/shared") setEnvelopeVersion(t, &updateCodex.Envelope, "2.0.0", "sha256:tree-v2", "sha256:manifest-v2") + updateCodex.Envelope.CatalogEvidence = evidence("sha256:catalog-v2") updateCodex.Confirmed = true updateCodex.OperationID = "operation-codex-v2" if result, err := service.Update(context.Background(), updateCodex); err != nil || result.NoChange || !result.Mutated { @@ -1125,7 +1144,8 @@ func TestMultiClientUpdateConvergesEachClientRevision(t *testing.T) { t.Fatal(err) } for _, clientID := range []domain.ClientID{domain.ClientCursor, domain.ClientCodex} { - if revision := revisionForClient(t, state.Installations[0], clientID); revision.Version != "2.0.0" || revision.TreeDigest != "sha256:tree-v2" { + if revision := revisionForClient(t, state.Installations[0], clientID); revision.Version != "2.0.0" || revision.TreeDigest != "sha256:tree-v2" || + revision.CatalogEvidence == nil || revision.CatalogEvidence.Digest != "sha256:catalog-v2" { t.Fatalf("%s revision = %+v", clientID, revision) } } diff --git a/npm/agentplugins/README.md b/npm/agentplugins/README.md index 47ec78cf..d69c798e 100644 --- a/npm/agentplugins/README.md +++ b/npm/agentplugins/README.md @@ -1,7 +1,8 @@ # Universal Agent Plugins -Install and manage portable Agent Plugins 1.0 packages across Codex/ChatGPT, -Cursor, GitHub Copilot/VS Code, and Kiro. +Install and manage portable Agent Plugins 1.0 packages across Codex, Cursor, +GitHub Copilot/VS Code, and Kiro. Agentplugins 0.1.6 adds the separate ChatGPT +target for official app-bound packages and catalog v2 entries. Prerequisite: Node.js 22 or newer. @@ -45,7 +46,7 @@ npx universal-agent-plugins remove context7 --target cursor For GitHub Copilot CLI, `agentplugins` performs the native install, update, and remove automatically through a managed local marketplace. VS Code discovers the same installation automatically, so selecting either target once is -enough. Codex/ChatGPT and Kiro print one exact, path-specific next step when +enough. Codex, ChatGPT (0.1.6+), and Kiro print one exact, path-specific next step when their client UI must finish the installation. Short names resolve through the pinned @@ -58,24 +59,29 @@ npx universal-agent-plugins add ./my-plugin --target cursor npx universal-agent-plugins add owner/repo@commit//plugins/my-plugin --target cursor ``` -The package must have a root `plugin.json` using the supported Agent Plugins -1.0 schema. Optional root `mcp.json` and `skills/*/SKILL.md` components are -installed only where the selected client supports them. The downloaded binary -and installed command remain `agentplugins`. +Accepted packages have either a portable root `plugin.json` with optional +`mcp.json`, `.app.json`, and `skills/`, or an official +`.codex-plugin/plugin.json` with its declared root `.mcp.json`, `.app.json`, and +`skills/` sidecars. The downloaded binary and installed command remain +`agentplugins`. -Each mutation changes one selected client and asks before changing it. v0.1 -supports user scope and reports whether a client can install automatically or -requires manual activation. For older `plugin-kit-ai` installations, run the -explicit migration before the first standard installation: +Each mutation changes one selected client. Human TTY sessions ask before a +change; non-TTY and JSON automation auto-confirm only when `--target` is +explicit, without requiring `--yes`. v0.1 supports user scope and reports +whether a client can install automatically or requires manual activation. For +older `plugin-kit-ai` installations, run the explicit migration before the +first standard installation: ```bash npx universal-agent-plugins migrate-state --dry-run npx universal-agent-plugins migrate-state ``` -The migration validates the complete State v2 result, creates a byte-for-byte +The migration validates the complete State v3 result, creates a byte-for-byte backup, and keeps legacy `plugin.yaml` packages on their original lifecycle -until an explicit `migrate-format`. +until an explicit `migrate-format`. agentplugins 0.1.6 reads existing State v2 +without rewriting it; the first explicit mutation saves State v3. After that, +0.1.5 fails closed and no silent downgrade is supported. Legacy migration intentionally shares the old `plugin-kit-ai` sentinel lock. If a crash leaves `~/.plugin-kit-ai/locks/state.lock`, first stop every