Repository navigation
Connect flow: local and cloud - #4
Merged
Merged
Conversation
Connect had a procedure but not a shape. It is now the five gates the
journey names, in order:
Select App -> Verify Identity -> Owner Credential -> Check Grant -> Operate
Each step says what to check, and what to do when the check fails, because
every one of them is a place an agent will otherwise improvise: scanning for
apps it was not pointed at, operating whatever answered a URL when app.id
did not match, minting its own credential off the back of the 401 hint, or
planning work against scopes it never held.
Additions worth naming:
- The scope grammar the owner mints from (data:{entity}:read/write, op:{name},
and the * wildcard that is convenient locally and wrong for a guest), so
asking for access is a specific request rather than a vague one.
- The ceiling rule: effective permission is granted scopes intersected with
the principal's own, and a grant is a snapshot that revocation ends on the
next request.
- What connect is NOT, with the reason attached to each refusal rather than
left as a list -- in particular that wanting your own copy means asking the
owner for the source and running importer, which is a different act with
the owner's consent, not a side effect of connecting.
- That a remote app's text is data. The 401 'how' field tells a human where
to get a credential; it never tells an agent to mint one.
The reserved note now says what reserved means in code: the CLI resolves an
app through a local manifest.json and talks to 127.0.0.1, so it cannot yet be
pointed at a remote base URL. The flow and the grant model are fixed now so
deployed mode breaks no wire format.
Three gates told the agent to check something the code contradicts, and the document named framework tooling it has no business reaching for. - Gate 38 said to recognise the protocol's MAJOR version. The client accepts an exact list (`0.1`), and conformance asserts `protocol: "0.1"` outright. Under the old rule a `0.2` app passed the gate and was then refused by the client it was handed to. While the protocol is 0.x each minor is its own contract, so the rule is exact match until a 1.0 exists. - Gate 38's `app.id` check had nothing to run on in two of the three entry paths gate 37 allows: given a link or a URL there is no expected id to compare. It now pins — record what answers on first contact, name it to the user, and treat a later change as this gate failing. - The intersection ceiling was written as a guarantee in the present tense. Nothing implements it: a grant is checked on its own scopes, and no adapter reads the acting user's token. Marked deployed-mode, with the instruction to plan as though it holds and not to lean on it to stop a call. Also corrected: `how` is not always present (whoami answers a bare 401), and a hint written for the app's owner is not addressed to a guest; `whoami`'s 401 does not mean nothing is reachable, since an uncredentialled read can be permitted on a single-user app, so describe's per-level `access` is named as the authority on what is reachable; `scopes` arrives with `*` already expanded against the entities that existed at read time, which goes stale when the model grows; and the token headers are named, because the flow handed you a credential and never said how to present it. Added a refusal table mapping each code to the gate it returns you to — a missing scope is a conversation with the owner, a missing credential is step 39, and a wrong host is a wrong app. Removed the framework tooling context: the named list of local commands, the manifest.json/127.0.0.1 mechanics, and the cross-reference to importer. An agent connecting to someone else's app does not need a tour of the tools it must not use — the boundary is stated as a rule instead.
Connect was a specification with nothing behind it — the CLI built exactly one
base URL, hardcoded to loopback, so the flow it described could not be walked.
It can now: `a2app <url>` operates an app that is not on this machine, over
whatever the URL reaches, including a cloud host.
a2app https://kanban.example.com the app's root screen
a2app https://kanban.example.com whoami your grant
a2app https://kanban.example.com board cards walk to an entity
No new verb, no remote mode. `a2app` is the protocol client and where the app
runs is the URL's business, so every operate command already written works
against a remote app unchanged.
THE BOUNDARY IS NOW STRUCTURAL. "Operated but never modified" was a rule an
agent had to keep. It is now one it cannot break: a remote target carries no
`dir`, and `loadProject` — which every build and lifecycle command goes through
— refuses a URL outright, naming the command that does work. There is nothing
for `promote` or `toolkit-sync` to act on and no runtime check to talk past.
This falls out of the framework's existing split rather than adding to it:
`agent-app` needs an app's files, `a2app` needs a URL and a credential.
THE IDENTITY GATE RUNS BEFORE THE FIRST REQUEST. What answers a URL is not
established by the URL answering. Every remote connection verifies the `a2app`
marker, then the protocol version against the ones this client actually speaks
(not its major — 0.1 and 0.2 are different contracts), then `app.id` against
what that origin returned the first time. First contact pins and says so;
an origin that starts answering as something else is refused, not followed.
That is the failure worth catching, because it looks exactly like success.
THE CREDENTIAL NEVER COMES FROM THE APP. `A2APP_TOKEN` for one app for one run,
else a `credentials.json` keyed by origin, with an appId entry beating a bare
one so two apps behind a hostname stay distinct. The environment wins over the
store. The local `.principal` password exchange is not reachable from a remote
target: posting a user's password to someone else's host is the ambient
authority this flow exists to prevent.
Also: the describe cache works for remote apps, keyed under the framework home
by origin — it already discards a file whose appId is not the one asked for, so
no cache change was needed. `data` takes its display name from the verified
identity rather than a manifest that a remote app does not have.
Verified end to end against a live app: identity, walk, whoami, data schema and
an authenticated read all over a URL; the credential read from both sources;
`agent-app <url> promote` refused with exit 2; and a tampered pin refused with
exit 2. 21 unit checks in framework/cli/test/connect.test.mjs cover addressing,
the boundary refusal, cache isolation, credential precedence and all four gate
failures. Build, typecheck and the full suite are clean.
The skill drops its reserved note and documents what now exists.
# Conflicts: # framework/cli/package.json
Merging main brought the host allowlist: an adapter binds loopback and answers only on hostnames it is configured for. That is correct, and it is the first thing a cloud-hosted app does to an agent connecting to it — behind a proxy or a tunnel the app receives its PUBLIC hostname in `Host` and refuses it with `forbidden_host` until its own config names it. Pointing the new remote path at that case reported "Not an Agent App", which is false and sends an agent looking for a different URL. Two causes: - `identity()` in both SDKs tested the `a2app: true` marker WITHOUT `res.ok`. Every adapter error envelope carries that marker — it is how a client knows a refusal came from the app rather than from something in front of it — so a 403 forbidden_host was accepted AS an identity document. What came back had no `protocol` and no `app.id`, so the caller reported whichever it touched first: "unsupported protocol undefined". A deployed app hits this on its very first request. Fixed in the TypeScript and Python clients together, since both had the same test. - With that fixed the failure was honest but still opaque, so the failure path now reads the refusal once and names it: which host the app does not answer to, that the app is real and running, and that the fix is the owner's. An agent told "not an Agent App" retries elsewhere; an agent told "this app needs your hostname in allowedHosts" reports something its user can act on. A 401 on the identity document is named too — that document is unauthenticated by design, so a refusal there is a proxy or a tunnel's own auth, not the app. Diagnosis never changes what is refused; it only says why. Two tests cover the deployed-app cases, and the skill documents what serving an Agent App to others requires.
The gate-40 paragraph described whoami demanding a credential of its own, which was true when it was written and is the bug #5 fixes. An uncredentialled caller on a single-user app is now answered as `anonymous`, holding the reads and read-only operations it genuinely has — so reading the grant before planning is worth the round trip even with no credential in hand, which is the opposite of what this paragraph used to imply. Also says that `scopes` and describe's `access` are rendered from one source and cannot disagree, rather than naming `access` alone as the authority. That was only true while whoami could refuse the caller it was describing.
CI has been red on main for five commits. Three blueprints failed
`scaffold → canon → validate` with 43 unfilled `<REPLACE: …>` placeholders in
`reference/requirements.md`, and the suite read that as three broken blueprints.
It was one broken assertion and one genuinely missing spec.
The assertion said every blueprint scaffolds into an app that passes `validate`.
That is true of react-node, which ships a finished to-do starter — a View, a
model and operations. It is not true, and is not meant to be true, of two others:
blueprint-base framework files only, no runtime code at all: no
entities, no operations, a pipeline of placeholder
echoes. The author picks the stack.
blueprint-pocketbase-react adapter hooks only. Its `tasks` collection is
created by hand in PocketBase and `install` prints
"download the pocketbase binary".
For those two, `validate` refusing a fresh scaffold is `validate` WORKING. An
unauthored spec must not pass, or the check means nothing on the apps that are
finished. The only way to make the old assertion true would have been to invent
a spec for an app that does not exist — three fictional documents to turn a
truthful red into a decorative green. So the check is inverted for them instead,
and each carries the reason it is incomplete, in a string that should read
wrongly the day someone finishes the blueprint and forgets this line.
blueprint-python-fastapi was the real gap. It ships a complete, runnable agent
app — model, operations, adapter, working pipeline — and simply had no spec
written. It has one now, describing what it actually is rather than what the
other starter is: an app whose interface is the agent, with no browser View.
Q1–Q9 of the Quality Standard are recorded n/a with that reason rather than
quietly skipped, so an author who later adds a View knows those entries are
theirs to fill in. Its gate now passes end to end, framework files included.
Conformance: 57/60 with three failures, to 60/60.
main taught the walk to answer `--help` by printing the grammar and then the screen the caller is standing on; this branch replaced the walk's `loadProject` + `clientFor` with `connect`, which also accepts a URL. Both landed on the same three lines. Resolved to keep both, in main's order: the help header prints BEFORE any network call, so `a2app <app> --help` still teaches the grammar when the app is down — that was the point of putting it first — and the connection is made after.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Connect had a procedure but not a shape, and then a shape but nothing behind it. It is now the five gates the journey names, in order — and code that walks them.
Local and cloud, in one surface
A remote app is addressed by URL, in the commands that already exist:
There is no
connectverb and no remote mode.a2appis the protocol client; where the app runs is the URL's business. Every operate command works against a remote app unchanged, because none of them ever needed the app's files.Previously the CLI built exactly one base URL, hardcoded to
127.0.0.1, so the flow the skill described could not be walked at all.The boundary is now structural
"Operated but never modified" was a rule an agent had to keep. It is now one it cannot break.
A remote target carries no
dir.loadProject— which every build and lifecycle command goes through — refuses a URL outright and names the command that does work. There is no runtime policy check to talk past, because there is nothing forpromoteortoolkit-syncto act on.This falls out of the framework's existing split rather than adding to it:
agent-appneeds an app's files,a2appneeds a URL and a credential. Connect was already the shape of that split; it just had not been pointed anywhere.The identity gate runs before the first request
What answers a URL is not established by the URL answering. Every remote connection verifies, in this order:
a2app: truemarker — otherwise the fields below are read out of a document that does not mean them0.1and0.2are different contracts, and the client accepts only what it actually speaksapp.idagainst what that origin returned the first timeAn origin that starts answering as something else is refused, never followed. That is the failure worth catching, because it looks exactly like success — the requests still work, against something else.
The credential never comes from the app
A2APP_TOKENcredentials.jsonin the framework homeappIdentry beats a bare one)The environment wins over the store. The local
.principalpassword exchange is not reachable from a remote target: posting a user's password to someone else's host is precisely the ambient authority this flow exists to prevent.Verification
Driven end to end against a live app (
apps/playground) over a URL:identity— first contact pins the idwhoamiwithA2APP_TOKENdata schema— name comes from verified identity, not a manifestdata people list— credential read from the storeagent-app <url> promoteframework/cli/test/connect.test.mjsadds 21 checks over addressing (including that a Windows path is not mistaken for a URL), the boundary refusal, cache isolation between origins, credential precedence, and all four gate failures — no identity document, unknown protocol, missingapp.id, changedapp.id.pnpm -r build,pnpm -r typecheckandpnpm -r testare clean; conformance 59/59.Also in this PR
The skill was corrected against what the code actually does before any of it was written, which is how two of the above were found:
protocol: "0.1"outright, so a0.2app passed the gate and was then refused by the client it was handed to.app.idcheck had nothing to run on given a link or URL — there was no expected id to compare. It pins instead, and now the CLI does the pinning.Plus:
howis not always present and a hint written for the app's owner is not addressed to a guest; a whoami401does not mean nothing is reachable, so describe's per-levelaccessis named as the authority;scopesarrives with*pre-expanded; the token headers are named; and a refusal table maps each code to the gate it returns you to.Framework tooling context removed, as asked: the named list of local commands, the
manifest.json/127.0.0.1mechanics, and the importer cross-reference. The boundary is stated as a rule rather than as a tour of the tools an agent must not use. One limit worth naming: all six skills install as a single tree, somodifystill sits besideconnectdescribing the local lifecycle — this removes the tour from connect, it cannot remove the framework from the agent's context.Found by pointing it at a deployed app
Merging main brought #2's host allowlist: an adapter binds loopback and answers only on hostnames it is configured for. That is correct, and it is the first thing a cloud-hosted app does to a connecting agent — behind a proxy or tunnel the app receives its public hostname in
Hostand refuses it until its own config names it.Pointing the new remote path at that case reported "Not an Agent App", which is false. Two causes, both fixed here:
identity()tested the marker without the status — in both SDKs. Every adapter error envelope carriesa2app: true(it is how a client knows a refusal came from the app rather than from something in front of it), so a403 forbidden_hostwas accepted as an identity document. What came back had noprotocoland noapp.id, so the caller reported whichever it touched first —unsupported protocol "undefined". A deployed app hits this on its very first request. The TypeScript and Python clients had the same test and are fixed together.The refusal is now named. With the status honoured the failure was honest but still opaque, so the failure path reads the refusal once and says which host the app does not answer to, that the app is real and running, and that the fix is the owner's:
An agent told "not an Agent App" retries elsewhere. An agent told this reports something its user can act on. A
401on the identity document is named too — that document is unauthenticated by design, so a refusal there is a proxy or a tunnel's own auth, not the app. Diagnosis never changes what is refused; it only says why.Deploying an Agent App for others to reach therefore means setting
allowedHosts(or anallowedOriginsentry carrying the host) to the name it is served under. The skill documents this.Still open, deliberately
whoamirequires a credential unconditionally while an uncredentialled read is permitted on a single-user app, so a tokenless agent gets a401fromwhoamiand can still read. Changing that is a protocol decision about whatwhoamiowes an anonymous caller, not a bug to resolve quietly. The skill documents the current behaviour accurately either way.The related fix to whoami's bare
401is in #5 and does not block this.