Skip to content

QA run · ai (FULL area) · 92f26f75 · 2026-08-11 · 4 PASS / 3 FAIL #7627

Description

@baozhoutao

Full ai area run of the checklist-test skill — all 7 items driven against a live showcase (opus subagent, isolated boot + file DB). Text-only per RUNNER.md.

Result: 4 PASS · 3 FAIL.

Environment — framework 92f26f75 (branch claude/platform-test-checklist-ocwugl) · vendored console 6314e87f · showcase app · isolated port + file DB · admin persona.

✅ PASS — 4 items

  • agent-tool-skill-metadata-roundtrip (6/6) — the valid agent/tool/skill trio builds clean through the real build gate, and all ten retired-key tombstone probes fail the build with the key named, its prescription, and the os migrate meta --from 16 line, with no artifact emitted for any. The registry serves the singular kinds with zero plural variants; round-trip diffs show source + exactly the documented defaults; runtime draft PUT works for skill/tool while agent is correctly refused 403 NOT_CREATABLE (code-only per ADR-0063); anonymous meta read 401. "Authored ⇒ callable" was deliberately not ticked — executing the tool 501s (no AI service in open edition).
  • mcp-run-action-exposure-gate (5/5) — an action without ai:{exposed:true} is refused over MCP with the exact actionAiExposureError text and the target record is byte-identical afterwards (same updated_at); list_actions advertises only the ai-declared action, so the two doors agree; the exposed action runs and its result reconciles with direct REST counts (14/5/12); the audit log names the calling user for a trusted-context body; sys_* actions (ban_user, set_user_role) are refused at both the object gate and the invoke-time guard with the target row unchanged. Trap wrong-persona ruled out deliberately: run as admin and the gate still refused — so it is the ai.exposed declaration, not a permission artifact.
  • skill-instructions-mcp-prompts (5/5) — prompts capability declared and the authored name/title/description projected; prompts/get returns the instructions byte-identical; an instruction-less skill and an inactive skill are both absent, and flipping the inactive one to active in the artifact makes it appear while the instruction-less one stays absent (absence proven then reversed, not merely asserted); prompts/get for an unknown/non-projected name errors -32602, never an empty success. Pins 12/12.
  • mcp-validate-expression (4/4) — a valid formula returns ok:true with inferredType and an inScope block listing the object's real fields/roots/functions; the three diagnostic tiers do not blur (bare-reference ERROR with the record. prescription · unknown-field ERROR with a did-you-mean · text-in-arithmetic ok:true with a type-mismatch WARNING); site-awareness proven by a paired call — the same bare expression is ok under flow_condition and an error under formula, and template flips inScope.dialect; unknown and system objects are refused with no field list or schema leaked.

🔴 FAIL — 3 items

1. mcp-stdio-fail-closed — the stdio transport starts, then is permanently deaf

The fail-closed half is perfect (clauses 0–2, 7): stdio enabled with no key → plugin start throws, kernel rolls back, port closed; an unknown/revoked key → same refusal with no anonymous-but-serving fallback; a real member key → principal-bound to that member, not admin; stdio off → clean boot with an explanatory line and HTTP still served.

Clause 3 fails: the started transport never answers a single JSON-RPC request. initialize, tools/list, resources/list, resources/read all time out with zero bytes on stdout, across 4 separate runs; malformed input draws no error either. Clauses 4–6 are blocked as a consequence.

Reproduction rule

  1. Spawn node packages/cli/bin/run.js serve -p <port> --dev with stdio:['pipe','pipe','pipe'], OS_MCP_STDIO_ENABLED=true and a valid osk_ key.
  2. Wait for [MCP] Server started (transport: stdio).
  3. Write a JSON-RPC initialize line to the child's stdin.

Expected an initialize result. Actual nothing, ever.

Root cause (located, with a captured stack). An instrumented probe 40 s after boot reports listeners(data)=1 readableFlowing=false isPaused=true bytesRead=0 — the data listener is attached but process.stdin is paused. oclif's argument parser opens a readline Interface over process.stdin and closes it on its abort signal, and Interface.close() calls stdin.pause():

at Interface.pause (node:internal/readline/interface:539)
at Interface.close (node:internal/readline/interface:525)
at signal.addEventListener.once (@oclif/core/lib/parser/parse.js:64)

Two controls isolate it to the host process, not the plugin: (a) a bare @modelcontextprotocol/sdk McpServer + StdioServerTransport answers the identical bytes, and (b) ObjectStack's own MCPServerRuntime({transport:'stdio'}) answers too — in a plain node process. So every objectstack serve/dev stdio transport is started-but-deaf: OS_MCP_STDIO_ENABLED=true advertises and logs a principal-bound stdio server that no MCP client can ever talk to. The 17/17 unit pins are green while the real CLI surface is deaf — the gap is above the plugin.

2. mcp-http-surface — two defects (incl. the stock showcase cannot serve MCP at all)

Passing: the skill doc is served anonymously as text/markdown carrying this environment's own URL; anonymous POST /mcp → 401 with an RFC 9728 WWW-Authenticate whose resource_metadata URL actually resolves; keyed initialize + tools/list return exactly the documented 11 tools with annotations; the config-off 404 and the implementation-missing 501 were both observed and correctly told apart. (The OAuth-scope clause is blocked(environment) — client_credentials tokens are not accepted as request principals anywhere, so the 403 insufficient_scope branch needs an interactive PKCE consent; the scope pins were run instead, 9/9.)

(a) 405 envelope drift. POST /api/v1/mcp/skill → 405 with a hand-rolled body {error,code,message,method,path,allowed} instead of the standard {success:false,error:{code,message,httpStatus}} and the documented "Method not allowed — use GET". Root cause: dispatcher-plugin.ts registers the skill route with server.get() only (unlike /mcp, mounted for POST+GET+DELETE), so a method mismatch never reaches the dispatcher — Hono routes it to notFound and the hono adapter's unmatchedResponse() answers with its own envelope. The domain's 405 branch (routed through buildApiError by #3842) is dead code on this adapter.

(b) The stock showcase cannot serve MCP at all. The boot banner advertises "MCP server — connect a coding agent: Endpoint …/api/v1/mcp", yet both /mcp and /mcp/skill answer 501 "MCP server is not available".

Reproduction rule — boot the stock showcase; curl the advertised endpoint. Expected the MCP surface the banner promises. Actual 501.

Root cause. serve.ts auto-adds mcp to requires, and CAPABILITY_PROVIDERS.mcp declares nameMatch: ['mcp-server','MCPServerPlugin','mcp']. hasPluginMatching does substring matching over loaded plugin names, and the showcase loads com.objectstack.connector.mcp — the outbound MCP client connector, which contains mcp — so the resolver concludes the capability is already provided and never loads MCPServerPlugin. Proven: removing only ConnectorMcpPlugin from a scratch copy makes [MCP] Plugin initialized appear and /mcp/skill answer 200; @objectstack/mcp was resolvable from the CLI the whole time. A substring match between a provider and a consumer plugin name is the general hazard here.

3. open-edition-honest-degradation — the anonymous-deny ordering is inverted on /ai/**

The degradation itself is honest and passes: /ai/models, /conversations, /usage, /chat all 501 with the Cloud/EE remedy sentence (mounted-but-unimplemented, never 404/503); /ai/agents returns the declared envelope with the relocated payload; and /discovery's services.ai message is string-identical to the 501 body.

Clause 3 fails. An unauthenticated GET /api/v1/ai/models answers 501 (leaking the Cloud/EE remedy sentence) instead of 401, and anonymous GET /ai/agents answers 200.

Reproduction rule — on a stock boot, with no Authorization header: GET /api/v1/ai/models → expected 401, actual 501; GET /api/v1/ai/agents → expected 401, actual 200. Reproduced twice. Control: anonymous GET /api/v1/data/showcase_task is 401 on the same boot, so anonymous-deny is otherwise live.

Root cause. runtime/src/domains/ai.ts handleAIRequest returns the agents courtesy + capabilityUnavailable() at the top of the handler, while the shouldDenyAnonymous gate lives inside the per-route loop further down — reachable only when the AI service is serveable. So on every open-edition deployment the whole /ai/** family answers unauthenticated callers. Disclosure impact is limited to two static strings, but the ordering contract this item exists to pin is inverted.

Smaller observations

  • GET /api/v1/meta/skill returns a skill twice after a runtime PUT — the store-override row and the package row are both listed, not merged/deduped by name. Related: the runtime metadata API's PUT /meta/skill/<n> {active:true} → 200 does not reach the prompt bridge, because listSkills calls metadataService.list('skill') (registry/package rows) while the HTTP meta list goes through protocol.getMetaItems (store overrides merged).
  • ?preview=draft self-inflicts an invalid diagnostic — reading a draft injects _draft:true into the item and then validates the item with that key, so the response carries _diagnostics.valid:false "Unrecognized key(s) … _draft".

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingqa-run

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions