feat(mcp,skills): sapiom_dev_app_publish + teach App Links in the preview skill (SAP-2922) - #722
Conversation
…view skill (SAP-2922) Studio's coding agent could only reach a sandbox preview URL, which dies with the sandbox's ttl — and nothing on that surface told it a durable alternative exists. Asked for "a link I can share with my team", it handed over a URL that would be dead by the time anyone clicked it. Add `sapiom_dev_app_publish` alongside the `sapiom_dev_sandbox_*` tools. It reads the SAME `sapiom.json` sandbox resource (source dir, start, port, optional build/env) and publishes it as an App Link — a durable https://apps.sapiom.ai/{org}/{slug} address — via the backend publish API in contract order: POST /v1/app-links (upsert on slug) → PUT /{id}/bundle → POST /{id}/publish, authed with the cached sapiom_authenticate credential as x-api-key. Nothing is provisioned here: no gateway call, no sandbox, no Blaxel. The wake happens later, on first visit, via app-host. Bundles are UTF-8 text only, so a binary is rejected by name locally, before any HTTP call — an agent that has to fix its input should not pay for an upload to learn that. Backend wire codes become errors that say what to change: BUNDLE_BINARY_FILE (names the file), BUNDLE_TOO_LARGE (quotes both sizes), PUBLIC_CONFIRM_REQUIRED, PUBLIC_SPEND_CAP_REQUIRED, APP_LINK_MANAGEMENT_PERMISSION_REQUIRED (drop the management fields, not a retry), 401, and 403 (names org.app_links.publish). Every branch states that nothing was published, so the next move is never a blind retry. Teaching, not just capability: both copies of the sapiom-sandbox-preview skill (byte-identical, guarded by the sync test) now say plainly that the preview URL expires with the ttl, and route "share this" / "permanent link" / "keep it alive" / "my link died" to the new tool, carrying the five facts an agent otherwise gets wrong — cold start, org-scoped by default, republish in place, text-only, ~10 MiB. The frontmatter triggers pick up durability asks, and a successful preview result now carries the hint at the moment the agent is about to hand the URL over. Refs: SAP-2922 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014AysBPbfr9uLL7inXTr9Np
Review — PR #722 (
|
…ublish (SAP-2922) Review of #722. Five real problems, all in the same area: what the tool tells the agent about the world after something goes wrong. Confidentiality. The module header of a file in a public, npm-published package named an internal authorization gap and its ticket, the per-run token prefix, a private planning-doc section as the normative contract, and an internal service name. Rewritten to the observable contract; the `interfaces §3` citations in the test file go too. "Nothing was published" was false for every failure after the first call. The upsert CREATES the link, so a BUNDLE_TOO_LARGE on step 2 left a real link — possibly public — on that slug while telling the agent nothing had happened, and offering no way to finish or clean up. Error copy is now step-aware: only a step-1 failure may claim nothing was created; later ones say the link exists with no active bundle and to publish the same slug again. The tests masked this — `mockResolvedValue` fired every case on call #1 — so the mock now plays the happy responses up to the failing step, and the created-then-failed path is covered at both the bundle and activate steps. Symlinks were followed out of the source directory. `statSync` follows links, so `report.txt -> ../../secrets` would have been read and published, and this destination can be a public URL, not a private sandbox. Now `lstatSync` and skip — which also removes the `a -> .` infinite recursion. The raced-sha comment claimed a mismatch was "visible" while nothing compared the two shas: the result paired another publisher's live sha with OUR manifest and file count. Now compared, with a `warning` field and a summary that stops claiming to describe the live bundle. Also: the 10 MiB cap is checked locally, measured exactly as the server measures it (canonical sorted-key JSON byte length), so the most likely cause of a dangling link never reaches the network; a 2xx body that is not an app link is refused instead of addressing `/v1/app-links/undefined/bundle`; and `sapiom.json` is skipped only at the bundle root, since a nested one is ordinary app content. The changeset copy carried the same false "nothing was published" claim into @sapiom/mcp's permanent CHANGELOG — corrected there as well. Refs: SAP-2922 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014AysBPbfr9uLL7inXTr9Np
|
Worked through all of these. Five fixes pushed in bd87a83; one finding I'm partly pushing back on. 🔒 Confidentiality — fixed. Confirmed the repo is public ( 🐛 "Nothing was published" after step 1 — fixed, and you were right that the tests masked it. The copy is now step-aware via a
🐛 Raced sha — fixed. You're right, the comment described a comparison that wasn't there, and I wrote that comment. Now: 🐛 Symlinks — fixed. Nits — two fixed, one pushed back on:
The one I'm not doing: adding a cleanup/rollback path (deleting the link when step 2 or 3 fails). The failure is now accurately reported and the recovery is named in the copy — republish the same slug, since Verification: |
Follow-up review — PR #722 (delta since
|
…(SAP-2922) Follow-up review of #722. Three regressions from the previous push, each a case of a check being stricter than what it was protecting. The source root. Moving to `lstatSync` was right for entries inside the tree and wrong for the root: a `source.path` pointing at a symlinked directory — ordinary in a workspace — reported `NO_SOURCE_DIR` and told the user to fix a path that was correct. The root is the path the user explicitly named, so it is resolved with `statSync`; only the walk refuses links. The project-config skip. `depth === 0 && entry === CONFIG_FILE` skips the BUNDLE root's sapiom.json, but that file is config only at `<projectDir>/sapiom.json`. Under `source.path: "web"` the real config was never in the tree, while a legitimate `web/sapiom.json` app asset was silently dropped. Now matched by absolute path against the project config — which is also what the rule always meant, and needs no depth counter. The publish response. `asAppLink` was written for step 1, where the `id` addresses the next two calls; applying it to step 3 turned a 204 or a bare `{ok:true}` — a publish that fully succeeded — into UNEXPECTED_RESPONSE "may or may not have taken effect". Split into `requireAppLink` (step 1, must be a link, attributed to the create step) and `asAppLink` returning null, with step 3 falling back to the step-1 link for url/id. A fallback also means no active sha to compare, so the race warning correctly stays silent. Refs: SAP-2922 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014AysBPbfr9uLL7inXTr9Np
|
All three are correct, and all three were regressions from my previous push. Fixed in d1e3bf1 — no pushback this round. 🐛 Symlinked source root — fixed. Right diagnosis: the move to // `statSync`, not `lstatSync`: the root is the path the user named, and a
// symlinked source directory must not be reported as a missing one.
if (!existsSync(root) || !statSync(root).isDirectory()) {Test covers 🐛 Root-only skip misfires under a non- 🐛
So a 204 or bare Verification: |
Closes SAP-2922 — https://linear.app/sapiom/issue/SAP-2922
Why
Studio's coding agent could only reach a sandbox preview URL, which dies with the sandbox's
ttl— and nothing on that surface told it a durable alternative exists. Asked for "a link I can share with my team", it handed over a URL that would be dead by the time anyone clicked it. The backend App Links REST API has been live on prod and dev; this closes the gap between it and the surface the agent is actually taught to use.What
sapiom_dev_app_publish(packages/mcp/src/tools/app-publish.ts) — the sibling ofsapiom_dev_sandbox_preview. Reads the samesapiom.jsontype: "sandbox"resource (source dir,start,port, optionalbuild/env), collects the source as a text-only file map, and publishes it as an App Link via the backend in contract order:POST /v1/app-links(upsert on slug)PUT /v1/app-links/{id}/bundlePOST /v1/app-links/{id}/publishAuth is the cached
sapiom_authenticatecredential asx-api-key. Returns{ summary, url, appLinkId, bundleSha256, manifest }. Nothing is provisioned here — no gateway call, no sandbox, no Blaxel; the wake happens later, on first visit, via app-host.Notable choices:
TextDecoder, not the lossyutf8readcollectDirFilesuses), so a binary in the source dir costs no round trip and creates no half-published link.sapiom.jsonis never bundled — it is project config, and itsenvblock holds the app's own secrets, which a staticstartcommand would otherwise serve to every visitor of a public link. The resource'senvtravels separately, on the upsert; itstier/ttldeliberately do not.POST /publishis sent with an empty body, matching the controller (it activates whatever bundle was last uploaded). The response'sbundleSha256is preferred over the uploaded one, so a racing publisher on the same slug is visible rather than reported as ours.BUNDLE_BINARY_FILE(names the file),BUNDLE_TOO_LARGE(quotes both sizes),PUBLIC_CONFIRM_REQUIRED,PUBLIC_SPEND_CAP_REQUIRED,APP_LINK_MANAGEMENT_PERMISSION_REQUIRED(drop the management fields — not a retry), 401, 403 (namesorg.app_links.publish), plusNETWORKand anHTTP_<status>fallback.Teaching, not just capability. Both copies of the
sapiom-sandbox-previewskill (byte-identical, guarded by the sync test) now say plainly that the preview URL expires with thettl, and add a "Make it durable / share it" section routing "share this" / "send this to my team" / "a permanent link" / "keep it alive" / "my link died" to the new tool — carrying the five facts an agent otherwise gets wrong: wake-on-demand cold start, org-scoped by default (public needsconfirmPublic+dailySpendCapUsd, so ask first), republish in place on the same slug, text-only bundles, ~10 MiB cap. The frontmatterdescriptionpicks up the durability triggers.Plus the nice-to-have: a successful
sapiom_dev_sandbox_previewresult now carries the hint on the result, not only in the tool description — the agent reads it at the moment it is about to hand the URL over.Tests
packages/mcp/src/tools/app-publish.test.ts(24 tests, backend mocked atfetch): the three-call order andx-api-key, the durability wording the routing decision hangs on, every wire-code mapping, binary rejection with no HTTP call, missing/empty source dir, republish keeping the URL with a new sha, base-path preservation on a customapiURL.skill-sync.test.tsgains content guards — byte-identity cannot stop two identically wrong copies.analytics.e2e.test.tstool roster updated.Verification
Not verified here: a live publish against
api.sapiom.dev(needs a funded org credential) — the acceptance criteria that need real HTTP are left for manual/dev-target checking.🤖 Generated with Claude Code
https://claude.ai/code/session_014AysBPbfr9uLL7inXTr9Np