Skip to content

Connect flow: local and cloud - #4

Merged
CraftOS-dev merged 9 commits into
mainfrom
korivi-Connect-V1
Sep 15, 2026
Merged

CraftOS-dev merged 9 commits into
mainfrom
korivi-Connect-V1

Conversation

@korivi-CraftOS

@korivi-CraftOS korivi-CraftOS commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

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.

Select App → Verify Identity → Owner Credential → Check Grant → Operate

Local and cloud, in one surface

A remote app is addressed by URL, in the commands that already exist:

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

There is no connect verb and no remote mode. a2app is 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.

agent-app https://kanban.example.com promote
→ https://kanban.example.com is a remote app — it can be operated, not modified.
  Building, evolving and lifecycle commands act on an app's files, which are on
  its own host. Use `a2app https://kanban.example.com …` to operate it.        (exit 2)

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 for promote or toolkit-sync to act on.

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. 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:

  1. the a2app: true marker — otherwise the fields below are read out of a document that does not mean them
  2. the exact protocol version, not its major — 0.1 and 0.2 are different contracts, and the client accepts only what it actually speaks
  3. app.id against what that origin returned the first time
⚠ First contact with https://kanban.example.com — pinning app.id "kanban".

→ https://kanban.example.com now identifies as "not-kanban", but was pinned to
  "kanban". Refusing to operate a different app than the one this origin was
  known for.                                                                   (exit 2)

An 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

Source Use it for
A2APP_TOKEN one app for one run — how a harness hands an agent a credential
credentials.json in the framework home several apps across runs, keyed by origin (an appId entry beats a bare one)

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 precisely the ambient authority this flow exists to prevent.

Verification

Driven end to end against a live app (apps/playground) over a URL:

Result
identity — first contact pins the id ✅
the walk — root screen, next-move hints echo the URL ✅
whoami with A2APP_TOKEN ✅
data schema — name comes from verified identity, not a manifest ✅
data people list — credential read from the store ✅
agent-app <url> promote ✅ refused, exit 2
tampered pin ✅ refused, exit 2

framework/cli/test/connect.test.mjs adds 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, missing app.id, changed app.id.

pnpm -r build, pnpm -r typecheck and pnpm -r test are 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:

  • Gate 38 said to recognise the protocol's MAJOR version. The client accepts an exact list and conformance asserts protocol: "0.1" outright, so a 0.2 app passed the gate and was then refused by the client it was handed to.
  • Gate 38's app.id check 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.
  • 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.

Plus: how is not always present and a hint written for the app's owner is not addressed to a guest; a whoami 401 does not mean nothing is reachable, so describe's per-level access is named as the authority; scopes arrives 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.1 mechanics, 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, so modify still sits beside connect describing 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 Host and 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 carries a2app: true (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. 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:

→ https://kanban.example.com is an Agent App, but it does not answer to that
  hostname. It replied 403 forbidden_host … the app's adapter needs
  "kanban.example.com" in its allowedHosts. Nothing you can send will change
  the answer.

An agent told "not an Agent App" retries elsewhere. An agent told this 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.

Deploying an Agent App for others to reach therefore means setting allowedHosts (or an allowedOrigins entry carrying the host) to the name it is served under. The skill documents this.

Still open, deliberately

whoami requires a credential unconditionally while an uncredentialled read is permitted on a single-user app, so a tokenless agent gets a 401 from whoami and can still read. Changing that is a protocol decision about what whoami owes 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 401 is in #5 and does not block this.

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.
@korivi-CraftOS korivi-CraftOS changed the title Connect flow Connect flow: local and cloud Sep 10, 2026
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.
@CraftOS-dev
CraftOS-dev merged commit cf89e7a into main Sep 15, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants