@nelsonlongxiang/dsh-open-a2a-net 0.6.38 → 0.6.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +342 -335
- package/README_zh.md +243 -236
- package/assets/nexusDist/assets/index-C7HagaGy.css +1 -1
- package/assets/nexusDist/assets/index-NkNSeIx6.js +9 -9
- package/cli/a2a.mjs +20 -5
- package/cli/serve.mjs +132 -0
- package/cordis.patch.yml +21 -21
- package/lib/a2a-client.js +1 -0
- package/lib/canvas-id.js +27 -0
- package/lib/canvas-store.js +7 -1
- package/lib/cli-deposit.js +2 -2
- package/lib/client.js +63 -63
- package/lib/config.js +125 -0
- package/lib/direct-forward.js +64 -18
- package/lib/faces/admin.js +4 -1
- package/lib/faces/wire.js +77 -7
- package/lib/flood-guard.js +3 -1
- package/lib/index.js +84 -172
- package/lib/lan-ip.js +20 -0
- package/lib/live-roots.js +39 -0
- package/lib/origin-claim.js +3 -2
- package/lib/peer-link-display.js +3 -2
- package/lib/queue-guard.js +3 -1
- package/lib/skill/references/cli.md +215 -203
- package/lib/skill/references/routing.md +4 -0
- package/lib/skill/references/setup.md +156 -153
- package/lib/task-auth.js +14 -10
- package/lib/types/canvas-id.d.ts +9 -0
- package/lib/types/cli-deposit.d.ts +6 -1
- package/lib/types/config.d.ts +278 -0
- package/lib/types/direct-forward.d.ts +25 -3
- package/lib/types/faces/admin.d.ts +2 -0
- package/lib/types/faces/wire.d.ts +26 -0
- package/lib/types/index.d.ts +3 -277
- package/lib/types/lan-ip.d.ts +7 -0
- package/lib/types/live-roots.d.ts +35 -0
- package/lib/types/origin-claim.d.ts +1 -0
- package/lib/types/peer-link-display.d.ts +2 -0
- package/lib/types/queue-guard.d.ts +2 -0
- package/lib/types/task-auth.d.ts +3 -0
- package/lib/types/types.d.ts +7 -0
- package/nexus-stage/package-lock.json +2262 -2262
- package/nexus-stage/src/edges.ts +72 -72
- package/nexus-stage/src/layout-wire.ts +158 -158
- package/nexus-stage/src/seat.ts +35 -35
- package/nexus-stage/src/stage-name.ts +13 -13
- package/nexus-stage/src/tokens.ts +31 -31
- package/package.json +219 -218
|
@@ -1,153 +1,156 @@
|
|
|
1
|
-
# Setup: one host, two peers, and beyond
|
|
2
|
-
|
|
3
|
-
A step-by-step path from a fresh install to a working round on three shapes
|
|
4
|
-
of network. Each step says WHO acts (owner or agent) and how to CHECK it
|
|
5
|
-
before moving on: most "it doesn't route" reports are a skipped check.
|
|
6
|
-
|
|
7
|
-
**Owner acts vs agent acts.** Installing the plugin, writing config,
|
|
8
|
-
restarting, joining a session to the network, adding exact names to
|
|
9
|
-
`teamJoinAllowlist`, and building canvas teams are human acts (the network
|
|
10
|
-
is human-established, gh#7 N1 / gh#11 N7). An agent prepares the exact
|
|
11
|
-
config, asks the owner to apply it, then verifies with the model tools. Never
|
|
12
|
-
drive the loopback control routes from a shell to do the owner's part:
|
|
13
|
-
presenting the operator's gesture on the operator's behalf is exactly the
|
|
14
|
-
#9 hole.
|
|
15
|
-
|
|
16
|
-
## 0. Preflight
|
|
17
|
-
|
|
18
|
-
| Check | How | Expect |
|
|
19
|
-
|---|---|---|
|
|
20
|
-
| Plugin present | `a2a_whoami` | `ok: true`. `face: session` inside a session, `host` otherwise |
|
|
21
|
-
| Version | `/__dsh_a2a/state` `version`, or the workbench header badge | the same version on every host you will link (0.6.28+) |
|
|
22
|
-
| Clock | `date` on each host | NTP-synced: card signatures carry `expiresAt`, and the OriginClaim envelope allows ±5 min |
|
|
23
|
-
|
|
24
|
-
## Human seat names
|
|
25
|
-
|
|
26
|
-
The owner can open the workbench header ⚙, select a local session under “会话席位名”, and explicitly save a stable display name (up to 64 characters; a name shaped like a route address such as `dsh/66abc2cf` is refused so it cannot pass for another session). Empty input clears the label. The `seatNames` map is stored in the home patch layer and hot-applies; cards and the board prefer it over the session title, and `a2a_whoami` / `a2a-net teams <name>` expose it. It never changes the route handle, join state or admission. Agents must ask the owner rather than self-name: A2A tool arguments containing `seatName`/`seatNames` are refused; no naming model tool exists. The owner control API keeps its existing apiKey/loopback trust model, not a sandbox against local shell or config-file writers.
|
|
27
|
-
|
|
28
|
-
## 1. One host: sessions talking to each other
|
|
29
|
-
|
|
30
|
-
**Owner:**
|
|
31
|
-
1. Install, then restart `dsh web`:
|
|
32
|
-
`dsh plugin --profile web add @nelsonlongxiang/dsh-open-a2a-net`
|
|
33
|
-
2. Leave the config at its defaults. `sessionNodes: true` exposes main
|
|
34
|
-
sessions as joinable nodes; no `apiKey` is needed while the listener is
|
|
35
|
-
bound to loopback.
|
|
36
|
-
3. Join each session that should collaborate: sidebar → A2A 网络 → toggle.
|
|
37
|
-
An unjoined session is invisible and refused (see
|
|
38
|
-
[membership.md](membership.md)).
|
|
39
|
-
4. Give the sessions a shared team (`teamScopeRouting` is on by default, so a
|
|
40
|
-
teamless session has no network). Pick ONE of:
|
|
41
|
-
- **Canvas team (recommended):** workbench `/__dsh_a2a_nexus/` → select
|
|
42
|
-
the session cards → 建队. Canvas membership is assigned by hand.
|
|
43
|
-
- **Roster team:** add the EXACT name (for example `dsh/review`) to
|
|
44
|
-
`teamJoinAllowlist` in the workbench owner panel (header ⚙). Each
|
|
45
|
-
session then declares it with `a2a_team_join { team: "dsh/review" }`.
|
|
46
|
-
A `*`/prefix entry authorizes nothing (`[PATTERN_NO_MINT]`).
|
|
47
|
-
|
|
48
|
-
**Agent, verify:**
|
|
49
|
-
- `a2a_whoami`: `node.joined: true`, the handle `<zone>/<id8>`, and the
|
|
50
|
-
team under `teams.roster` or `teams.canvas`.
|
|
51
|
-
- `a2a_teams`: the other session's handle is listed.
|
|
52
|
-
- One sync round:
|
|
53
|
-
`a2a_route { team: "<other handle>", message: "ping: reply 'pong'" }`.
|
|
54
|
-
A local target gets up to 180 s. Continue the conversation with the
|
|
55
|
-
returned `context_id`.
|
|
56
|
-
|
|
57
|
-
## 2. Two hosts that can reach each other (peers)
|
|
58
|
-
|
|
59
|
-
Do section 1 on both hosts first. Then the **owner**, on BOTH hosts:
|
|
60
|
-
|
|
61
|
-
1. **Reachability, both ways.** Each host must open the other's web
|
|
62
|
-
listener (LAN, VPN or tunnel). There is no built-in NAT traversal.
|
|
63
|
-
2. **One shared `apiKey`.** Bind beyond loopback only with a key. The
|
|
64
|
-
delivery face `/a2a/direct` and the log face require `X-API-Key` when a
|
|
65
|
-
key is set, and a host sends ITS OWN `apiKey` on every peer request. So
|
|
66
|
-
keyed hosts that talk to each other must carry the SAME key (or both
|
|
67
|
-
none, loopback-only). A mismatch shows up as HTTP 401
|
|
68
|
-
`X-API-Key required on the delivery face`.
|
|
69
|
-
3. **Announce and seed each other.** Seeding BOTH ways is what makes the
|
|
70
|
-
hosts linked: each side then reads the other's full card. One-sided
|
|
71
|
-
seeding does not link.
|
|
72
|
-
|
|
73
|
-
```yaml
|
|
74
|
-
- id: a2a
|
|
75
|
-
name: '@nelsonlongxiang/dsh-open-a2a-net'
|
|
76
|
-
config:
|
|
77
|
-
announce: true
|
|
78
|
-
apiKey: '<same secret on every linked host>'
|
|
79
|
-
peers: ['http://<other-host>:<port>'] # the other side lists this host
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
Restart. `announce` toggled from the owner panel applies on the next
|
|
83
|
-
restart.
|
|
84
|
-
4. **Link the sessions across hosts with a canvas team.** Hosts default to
|
|
85
|
-
the same zone name (`team: 'dsh'`), so a remote caller `dsh/<id8>`
|
|
86
|
-
resolves to no local session. A roster declaration cannot admit it,
|
|
87
|
-
because the roster holds this host's own sessions only. The record that
|
|
88
|
-
admits it is a **canvas team naming both exact handles**. Build it in the
|
|
89
|
-
workbench (建队 with the local session, then add the remote handle):
|
|
90
|
-
- on the host that receives rounds, or
|
|
91
|
-
- on either host, once they are linked (step 3).
|
|
92
|
-
|
|
93
|
-
The other side then reads that board's `canvasOrg` from the peer's full
|
|
94
|
-
card it has cached (gh#71, recognized both ways). `a2a_probe` warms that
|
|
95
|
-
cache. Receipts flowing back are admitted the same way.
|
|
96
|
-
|
|
97
|
-
Admission is target-scoped (F5): the remote member reaches only the
|
|
98
|
-
sessions inside that canvas team. A linked peer can also edit your canvas
|
|
99
|
-
(add or remove members, dissolve a team) through
|
|
100
|
-
`/__dsh_a2a/fleet-canvas-op`; your host's link gate decides.
|
|
101
|
-
|
|
102
|
-
**Agent, verify on each host:**
|
|
103
|
-
- `a2a_probe`: the peer is reachable. A miss is classified as `unreachable`
|
|
104
|
-
(network, port, tunnel) or `rejected` (key, signature, clock).
|
|
105
|
-
- State face `remote` rows: the peer's sessions appear, with `via` set to
|
|
106
|
-
the peer URL. `a2a_teams` shows peer rows only for teams in this host's
|
|
107
|
-
joined scope (S5, `teamScopeInfo`), so an empty list is not proof that the
|
|
108
|
-
link failed.
|
|
109
|
-
- One async round with a receipt:
|
|
110
|
-
`a2a_route { team: "<remote handle>", message: "...", async: true }`,
|
|
111
|
-
then check `a2a_tasks` until the row lands in `archived` with
|
|
112
|
-
`endedBy: receipt`. A peer delivery rides a 15 s transport budget:
|
|
113
|
-
`-32005` means it was delivered and is still running. Reconcile; never
|
|
114
|
-
resend.
|
|
115
|
-
|
|
116
|
-
## 3. Beyond direct peers (A cannot reach C)
|
|
117
|
-
|
|
118
|
-
Home-host one-hop forwarding shipped in 0.6.34 and defaults **on from 0.6.38**. Explicit `forwardDirect: false` restores local-only CLI delivery. It only follows a local miss for a bound-signed, online CLI deposited on that home, inside the unchanged sender scope. A must directly link B's receiving member (all advertised potential receivers for a canvas), and B must link the CLI and pin A as home. The home must not be isolated and any inbound Guard prevents a second forward. The outbound hop budget remains DEFAULT-1. No links are auto-created or relaxed.
|
|
119
|
-
|
|
120
|
-
Long forwarded work should use async `route` plus `inbox --wait --renew`, not expect a 15s `--sync` transport window to cover an entire model turn. A late final still produces the original task's receipt, and the forwarded row keeps selected-peer receiver facts for enforced settlement.
|
|
121
|
-
|
|
122
|
-
Other connectivity options (not authority bypasses):
|
|
123
|
-
|
|
124
|
-
1. **Make it a peer pair.** Open a tunnel or port-forward so A reaches C,
|
|
125
|
-
then do section 2 for A↔C. This is the simplest and keeps receipts
|
|
126
|
-
end-to-end.
|
|
127
|
-
2. **Delegate through B.** Route the task to a session on B, the host both
|
|
128
|
-
sides reach, and have that session route on to C and report back. Each
|
|
129
|
-
hop is its own task with its own receipt: B's ledger owns the B→C leg.
|
|
130
|
-
Keep messages self-contained and use `async: true` per hop.
|
|
131
|
-
3. **Names across zones.** `delegates` publishes signed name records, so a
|
|
132
|
-
name can resolve through other zones (≤5 hops, loops fail closed). That
|
|
133
|
-
is name resolution, not message forwarding: the final delivery still
|
|
134
|
-
goes straight to the resolved host, so that host must be reachable.
|
|
135
|
-
|
|
136
|
-
## 4. Callers outside a session
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
|
147
|
-
|
|
148
|
-
| `
|
|
149
|
-
| `
|
|
150
|
-
|
|
|
151
|
-
| `
|
|
152
|
-
| `
|
|
153
|
-
|
|
|
1
|
+
# Setup: one host, two peers, and beyond
|
|
2
|
+
|
|
3
|
+
A step-by-step path from a fresh install to a working round on three shapes
|
|
4
|
+
of network. Each step says WHO acts (owner or agent) and how to CHECK it
|
|
5
|
+
before moving on: most "it doesn't route" reports are a skipped check.
|
|
6
|
+
|
|
7
|
+
**Owner acts vs agent acts.** Installing the plugin, writing config,
|
|
8
|
+
restarting, joining a session to the network, adding exact names to
|
|
9
|
+
`teamJoinAllowlist`, and building canvas teams are human acts (the network
|
|
10
|
+
is human-established, gh#7 N1 / gh#11 N7). An agent prepares the exact
|
|
11
|
+
config, asks the owner to apply it, then verifies with the model tools. Never
|
|
12
|
+
drive the loopback control routes from a shell to do the owner's part:
|
|
13
|
+
presenting the operator's gesture on the operator's behalf is exactly the
|
|
14
|
+
#9 hole.
|
|
15
|
+
|
|
16
|
+
## 0. Preflight
|
|
17
|
+
|
|
18
|
+
| Check | How | Expect |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| Plugin present | `a2a_whoami` | `ok: true`. `face: session` inside a session, `host` otherwise |
|
|
21
|
+
| Version | `/__dsh_a2a/state` `version`, or the workbench header badge | the same version on every host you will link (0.6.28+) |
|
|
22
|
+
| Clock | `date` on each host | NTP-synced: card signatures carry `expiresAt`, and the OriginClaim envelope allows ±5 min |
|
|
23
|
+
|
|
24
|
+
## Human seat names
|
|
25
|
+
|
|
26
|
+
The owner can open the workbench header ⚙, select a local session under “会话席位名”, and explicitly save a stable display name (up to 64 characters; a name shaped like a route address such as `dsh/66abc2cf` is refused so it cannot pass for another session). Empty input clears the label. The `seatNames` map is stored in the home patch layer and hot-applies; cards and the board prefer it over the session title, and `a2a_whoami` / `a2a-net teams <name>` expose it. It never changes the route handle, join state or admission. Agents must ask the owner rather than self-name: A2A tool arguments containing `seatName`/`seatNames` are refused; no naming model tool exists. The owner control API keeps its existing apiKey/loopback trust model, not a sandbox against local shell or config-file writers.
|
|
27
|
+
|
|
28
|
+
## 1. One host: sessions talking to each other
|
|
29
|
+
|
|
30
|
+
**Owner:**
|
|
31
|
+
1. Install, then restart `dsh web`:
|
|
32
|
+
`dsh plugin --profile web add @nelsonlongxiang/dsh-open-a2a-net`
|
|
33
|
+
2. Leave the config at its defaults. `sessionNodes: true` exposes main
|
|
34
|
+
sessions as joinable nodes; no `apiKey` is needed while the listener is
|
|
35
|
+
bound to loopback.
|
|
36
|
+
3. Join each session that should collaborate: sidebar → A2A 网络 → toggle.
|
|
37
|
+
An unjoined session is invisible and refused (see
|
|
38
|
+
[membership.md](membership.md)).
|
|
39
|
+
4. Give the sessions a shared team (`teamScopeRouting` is on by default, so a
|
|
40
|
+
teamless session has no network). Pick ONE of:
|
|
41
|
+
- **Canvas team (recommended):** workbench `/__dsh_a2a_nexus/` → select
|
|
42
|
+
the session cards → 建队. Canvas membership is assigned by hand.
|
|
43
|
+
- **Roster team:** add the EXACT name (for example `dsh/review`) to
|
|
44
|
+
`teamJoinAllowlist` in the workbench owner panel (header ⚙). Each
|
|
45
|
+
session then declares it with `a2a_team_join { team: "dsh/review" }`.
|
|
46
|
+
A `*`/prefix entry authorizes nothing (`[PATTERN_NO_MINT]`).
|
|
47
|
+
|
|
48
|
+
**Agent, verify:**
|
|
49
|
+
- `a2a_whoami`: `node.joined: true`, the handle `<zone>/<id8>`, and the
|
|
50
|
+
team under `teams.roster` or `teams.canvas`.
|
|
51
|
+
- `a2a_teams`: the other session's handle is listed.
|
|
52
|
+
- One sync round:
|
|
53
|
+
`a2a_route { team: "<other handle>", message: "ping: reply 'pong'" }`.
|
|
54
|
+
A local target gets up to 180 s. Continue the conversation with the
|
|
55
|
+
returned `context_id`.
|
|
56
|
+
|
|
57
|
+
## 2. Two hosts that can reach each other (peers)
|
|
58
|
+
|
|
59
|
+
Do section 1 on both hosts first. Then the **owner**, on BOTH hosts:
|
|
60
|
+
|
|
61
|
+
1. **Reachability, both ways.** Each host must open the other's web
|
|
62
|
+
listener (LAN, VPN or tunnel). There is no built-in NAT traversal.
|
|
63
|
+
2. **One shared `apiKey`.** Bind beyond loopback only with a key. The
|
|
64
|
+
delivery face `/a2a/direct` and the log face require `X-API-Key` when a
|
|
65
|
+
key is set, and a host sends ITS OWN `apiKey` on every peer request. So
|
|
66
|
+
keyed hosts that talk to each other must carry the SAME key (or both
|
|
67
|
+
none, loopback-only). A mismatch shows up as HTTP 401
|
|
68
|
+
`X-API-Key required on the delivery face`.
|
|
69
|
+
3. **Announce and seed each other.** Seeding BOTH ways is what makes the
|
|
70
|
+
hosts linked: each side then reads the other's full card. One-sided
|
|
71
|
+
seeding does not link.
|
|
72
|
+
|
|
73
|
+
```yaml
|
|
74
|
+
- id: a2a
|
|
75
|
+
name: '@nelsonlongxiang/dsh-open-a2a-net'
|
|
76
|
+
config:
|
|
77
|
+
announce: true
|
|
78
|
+
apiKey: '<same secret on every linked host>'
|
|
79
|
+
peers: ['http://<other-host>:<port>'] # the other side lists this host
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Restart. `announce` toggled from the owner panel applies on the next
|
|
83
|
+
restart.
|
|
84
|
+
4. **Link the sessions across hosts with a canvas team.** Hosts default to
|
|
85
|
+
the same zone name (`team: 'dsh'`), so a remote caller `dsh/<id8>`
|
|
86
|
+
resolves to no local session. A roster declaration cannot admit it,
|
|
87
|
+
because the roster holds this host's own sessions only. The record that
|
|
88
|
+
admits it is a **canvas team naming both exact handles**. Build it in the
|
|
89
|
+
workbench (建队 with the local session, then add the remote handle):
|
|
90
|
+
- on the host that receives rounds, or
|
|
91
|
+
- on either host, once they are linked (step 3).
|
|
92
|
+
|
|
93
|
+
The other side then reads that board's `canvasOrg` from the peer's full
|
|
94
|
+
card it has cached (gh#71, recognized both ways). `a2a_probe` warms that
|
|
95
|
+
cache. Receipts flowing back are admitted the same way.
|
|
96
|
+
|
|
97
|
+
Admission is target-scoped (F5): the remote member reaches only the
|
|
98
|
+
sessions inside that canvas team. A linked peer can also edit your canvas
|
|
99
|
+
(add or remove members, dissolve a team) through
|
|
100
|
+
`/__dsh_a2a/fleet-canvas-op`; your host's link gate decides.
|
|
101
|
+
|
|
102
|
+
**Agent, verify on each host:**
|
|
103
|
+
- `a2a_probe`: the peer is reachable. A miss is classified as `unreachable`
|
|
104
|
+
(network, port, tunnel) or `rejected` (key, signature, clock).
|
|
105
|
+
- State face `remote` rows: the peer's sessions appear, with `via` set to
|
|
106
|
+
the peer URL. `a2a_teams` shows peer rows only for teams in this host's
|
|
107
|
+
joined scope (S5, `teamScopeInfo`), so an empty list is not proof that the
|
|
108
|
+
link failed.
|
|
109
|
+
- One async round with a receipt:
|
|
110
|
+
`a2a_route { team: "<remote handle>", message: "...", async: true }`,
|
|
111
|
+
then check `a2a_tasks` until the row lands in `archived` with
|
|
112
|
+
`endedBy: receipt`. A peer delivery rides a 15 s transport budget:
|
|
113
|
+
`-32005` means it was delivered and is still running. Reconcile; never
|
|
114
|
+
resend.
|
|
115
|
+
|
|
116
|
+
## 3. Beyond direct peers (A cannot reach C)
|
|
117
|
+
|
|
118
|
+
Home-host one-hop forwarding shipped in 0.6.34 and defaults **on from 0.6.38**. Explicit `forwardDirect: false` restores local-only CLI delivery. It only follows a local miss for a bound-signed, online CLI deposited on that home, inside the unchanged sender scope. A must directly link B's receiving member (all advertised potential receivers for a canvas), and B must link the CLI and pin A as home. The home must not be isolated and any inbound Guard prevents a second forward. The outbound hop budget remains DEFAULT-1. No links are auto-created or relaxed.
|
|
119
|
+
|
|
120
|
+
Long forwarded work should use async `route` plus `inbox --wait --renew`, not expect a 15s `--sync` transport window to cover an entire model turn. A late final still produces the original task's receipt, and the forwarded row keeps selected-peer receiver facts for enforced settlement.
|
|
121
|
+
|
|
122
|
+
Other connectivity options (not authority bypasses):
|
|
123
|
+
|
|
124
|
+
1. **Make it a peer pair.** Open a tunnel or port-forward so A reaches C,
|
|
125
|
+
then do section 2 for A↔C. This is the simplest and keeps receipts
|
|
126
|
+
end-to-end.
|
|
127
|
+
2. **Delegate through B.** Route the task to a session on B, the host both
|
|
128
|
+
sides reach, and have that session route on to C and report back. Each
|
|
129
|
+
hop is its own task with its own receipt: B's ledger owns the B→C leg.
|
|
130
|
+
Keep messages self-contained and use `async: true` per hop.
|
|
131
|
+
3. **Names across zones.** `delegates` publishes signed name records, so a
|
|
132
|
+
name can resolve through other zones (≤5 hops, loops fail closed). That
|
|
133
|
+
is name resolution, not message forwarding: the final delivery still
|
|
134
|
+
goes straight to the resolved host, so that host must be reachable.
|
|
135
|
+
|
|
136
|
+
## 4. Callers outside a session
|
|
137
|
+
|
|
138
|
+
For an always-on CLI home that survives DSH restarts, run the network layer without a DSH instance: `a2a-net serve` — deployment, service files, linking and the public-internet caveats are in `docs/guides/standalone-node.md` of the repository.
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
Scripts and terminals use the `a2a-net` CLI: deposit on ONE home host, read
|
|
142
|
+
a mailbox, no wake. The home can forward eligible requests one hop under section 3; an online deposit alone grants no remote scope. See [cli.md](cli.md).
|
|
143
|
+
|
|
144
|
+
## 5. Symptom → step
|
|
145
|
+
|
|
146
|
+
| Symptom | Go back to |
|
|
147
|
+
|---|---|
|
|
148
|
+
| `declares no team membership` / `shares no team` | §1.4 (same host) or §2.4 (cross-host canvas ref on the target) |
|
|
149
|
+
| `caller_session is required` | the caller is not a joined session (§1.3) or an external client omitted it |
|
|
150
|
+
| HTTP 401 `X-API-Key required` | §2.2: keys differ between the hosts |
|
|
151
|
+
| `a2a_probe` → `rejected` | §2.2 key, or §0 clock; also card signature/expiry |
|
|
152
|
+
| `a2a_probe` → `unreachable` | §2.1 reachability (bind address, firewall, tunnel) |
|
|
153
|
+
| peer reachable but its sessions never appear | §2.3: seeding is one-sided, or the peer did not `announce` |
|
|
154
|
+
| `No live DSH session node accepts team X` | X is on another host and nothing forwards (§3), or X is not joined/live there |
|
|
155
|
+
| `[PATTERN_NO_MINT]` / `[ALLOWLIST_DENY]` | §1.4: add the EXACT team name to the allowlist |
|
|
156
|
+
| `[CANVAS_TEAM]` from `a2a_team_join` | canvas teams are built in the workbench, not joined by tool |
|
package/lib/task-auth.js
CHANGED
|
@@ -32,7 +32,11 @@ export const VIA_OF = {
|
|
|
32
32
|
/** The refusal text of a decision, or undefined when admitted — the shape the faces have always read. */
|
|
33
33
|
export const refusalOf = (admission) => (admission.ok ? undefined : admission.refusal);
|
|
34
34
|
const admitted = (rule, basis = []) => ({ ok: true, via: VIA_OF[rule], rule, basis });
|
|
35
|
-
|
|
35
|
+
/** #282 S1: a refusal may carry the one step the caller (or this host's operator) can take next. */
|
|
36
|
+
const refused = (refusal, next) => ({ ok: false, refusal, ...(next !== undefined ? { next } : {}) });
|
|
37
|
+
/** The next step of a refused decision, or undefined. */
|
|
38
|
+
export const nextOf = (a) => (a.ok ? undefined : a.next);
|
|
39
|
+
const linkNext = (caller, target) => `ask this host's operator to put ${caller} in a canvas team${target !== undefined ? ` with ${target}` : ''} in the A2A workbench, or route through a member of a team you already share`;
|
|
36
40
|
const ZONE_ID8 = /^[a-z0-9-]+\/[0-9a-f]{8}$/i;
|
|
37
41
|
const teamlessText = (caller) => `caller "${caller}" declares no team membership on this host's roster — a teamless node has no network (S3 phase-3 admission). Declare a shared team on both ends, or route through a teamed member.`;
|
|
38
42
|
/**
|
|
@@ -178,7 +182,7 @@ export function sameZoneAdmission(r, caller, targetTeam) {
|
|
|
178
182
|
return hit;
|
|
179
183
|
return refused(declared.size === 0
|
|
180
184
|
? teamlessText(caller)
|
|
181
|
-
: `caller "${caller}" shares no team with target "${targetTeam}" — declared [${[...declared.keys()].join(', ')}], target links [${[...links].join(', ')}] (F5 target-level admission: a host-level link alone does not open every session team)
|
|
185
|
+
: `caller "${caller}" shares no team with target "${targetTeam}" — declared [${[...declared.keys()].join(', ')}], target links [${[...links].join(', ')}] (F5 target-level admission: a host-level link alone does not open every session team).`, linkNext(caller, targetTeam));
|
|
182
186
|
}
|
|
183
187
|
}
|
|
184
188
|
const hit = pass(t => routable.has(t) || otherLocalDeclarations.has(t));
|
|
@@ -186,7 +190,7 @@ export function sameZoneAdmission(r, caller, targetTeam) {
|
|
|
186
190
|
return hit;
|
|
187
191
|
return refused(declared.size === 0
|
|
188
192
|
? teamlessText(caller)
|
|
189
|
-
: `caller "${caller}" declares teams [${[...declared.keys()].join(', ')}] — none is routable on this node (shared-team admission). Declare a shared team on both ends, or route through a teamed member
|
|
193
|
+
: `caller "${caller}" declares teams [${[...declared.keys()].join(', ')}] — none is routable on this node (shared-team admission). Declare a shared team on both ends, or route through a teamed member.`, linkNext(caller, targetTeam));
|
|
190
194
|
}
|
|
191
195
|
/**
|
|
192
196
|
* W1: the joined-face gate for the LOCAL dispatch candidate — only a
|
|
@@ -216,7 +220,7 @@ export function callerAdmission(r, caller, targetTeam, cliProof) {
|
|
|
216
220
|
if (!r.teamScopeRouting)
|
|
217
221
|
return admitted('scope-off');
|
|
218
222
|
if (caller === '') {
|
|
219
|
-
return refused('caller_session is required — anonymous deliveries are refused (S3 phase-3b). Pass caller_session as <zone>/<id8>.');
|
|
223
|
+
return refused('caller_session is required — anonymous deliveries are refused (S3 phase-3b). Pass caller_session as <zone>/<id8>.', 'send caller_session as your <zone>/<id8> handle (a2a-net does this for you)');
|
|
220
224
|
}
|
|
221
225
|
if (caller === r.zone)
|
|
222
226
|
return admitted('host-face');
|
|
@@ -228,21 +232,21 @@ export function callerAdmission(r, caller, targetTeam, cliProof) {
|
|
|
228
232
|
const basis = r.canvasNames().filter(name => r.canvasRemoteMembersOf(name).includes(caller)).map(name => r.canvasTeamOf(name));
|
|
229
233
|
if (basis.length > 0)
|
|
230
234
|
return admitted('remote-ref-foreign', basis);
|
|
231
|
-
return refused(`caller "${caller}" has no operator-established link on this node — cross-host admission requires a canvas remote membership this host's operator added (N3/N4: the caller's own card claims are self-description, not links). Ask this host's operator to add the member in the A2A workbench, or route through a linked member
|
|
235
|
+
return refused(`caller "${caller}" has no operator-established link on this node — cross-host admission requires a canvas remote membership this host's operator added (N3/N4: the caller's own card claims are self-description, not links). Ask this host's operator to add the member in the A2A workbench, or route through a linked member.`, linkNext(caller, targetTeam));
|
|
232
236
|
}
|
|
233
237
|
// A foreign CLI handle is never a free-form node fact: its operator link,
|
|
234
238
|
// pinned home, bound signer, and fresh online row must ALL hold.
|
|
235
239
|
if (CLI_HANDLE_RE.test(caller) && !r.cliDeposited(caller)) {
|
|
236
240
|
const basis = r.canvasNames().filter(name => r.canvasRemoteMembersOf(name).includes(caller)).map(name => r.canvasTeamOf(name));
|
|
237
241
|
if (basis.length === 0)
|
|
238
|
-
return refused(`remote CLI ${caller} has no operator-established canvas link
|
|
242
|
+
return refused(`remote CLI ${caller} has no operator-established canvas link`, linkNext(caller, targetTeam));
|
|
239
243
|
const home = r.cliHomeOf?.(caller);
|
|
240
244
|
if (home === undefined)
|
|
241
|
-
return refused(`remote CLI ${caller} home is not pinned`);
|
|
245
|
+
return refused(`remote CLI ${caller} home is not pinned`, `ask this host's operator to pin ${caller}'s home host when adding it to the canvas team (workbench member dialog)`);
|
|
242
246
|
if (cliProof?.bound !== true || cliHomeOrigin(cliProof.origin) !== home || cliHomeOrigin(cliProof.viaUrl) !== home)
|
|
243
|
-
return refused(`remote CLI ${caller} signer does not match pinned home`);
|
|
247
|
+
return refused(`remote CLI ${caller} signer does not match pinned home`, `send through ${caller}'s pinned home host only`);
|
|
244
248
|
if (cliProof.online !== true)
|
|
245
|
-
return refused(`remote CLI ${caller} is offline or home card could not be read`);
|
|
249
|
+
return refused(`remote CLI ${caller} is offline or home card could not be read`, 'run `a2a-net publish` on the home host, then send again with a new task id');
|
|
246
250
|
return admitted('remote-ref-cli', basis);
|
|
247
251
|
}
|
|
248
252
|
// Free-form label: only this host's own node facts (V3).
|
|
@@ -257,7 +261,7 @@ export function callerAdmission(r, caller, targetTeam, cliProof) {
|
|
|
257
261
|
// gh#45: a deposited CLI handle is a published node fact of this host.
|
|
258
262
|
if (r.cliDeposited(caller))
|
|
259
263
|
return admitted('cli-deposit');
|
|
260
|
-
return refused(`caller_session "${caller}" matches no node fact on this host (joined session id, live node label, or deposited CLI handle) — free-form caller labels are self-description and are not admissible (N3). Pass caller_session as <zone>/<id8> with an operator-established link
|
|
264
|
+
return refused(`caller_session "${caller}" matches no node fact on this host (joined session id, live node label, or deposited CLI handle) — free-form caller labels are self-description and are not admissible (N3). Pass caller_session as <zone>/<id8> with an operator-established link.`, 'send caller_session as a <zone>/<id8> handle this host has linked; free-form labels are never admitted');
|
|
261
265
|
}
|
|
262
266
|
/** Revalidate only mutable operator authority, not already-verified birth-time identity or liveness. */
|
|
263
267
|
export function recheckRemoteCliAuthorization(r, auth) {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** First 8 hex of sha256 over the base64 SPKI DER public key; '' when there is no key. */
|
|
2
|
+
export declare function canvasOwnerFingerprint(publicKey: string | undefined): string;
|
|
3
|
+
/** Split `<zone>/canvas/<name>@<fp8>` into the plain team and the owner fingerprint. */
|
|
4
|
+
export declare function splitCanvasOwner(team: string): {
|
|
5
|
+
team: string;
|
|
6
|
+
owner?: string;
|
|
7
|
+
};
|
|
8
|
+
/** The display/routing id of a canvas team owned by the card holding `publicKey`. */
|
|
9
|
+
export declare function qualifiedCanvasId(team: string, publicKey: string | undefined): string;
|
|
@@ -39,6 +39,11 @@ export interface CliMail {
|
|
|
39
39
|
/** gh#113: set when `text` was cut to MAILBOX_TEXT_CAP; `chars` is the original length. */
|
|
40
40
|
readonly truncated?: true;
|
|
41
41
|
readonly chars?: number;
|
|
42
|
+
/**
|
|
43
|
+
* #298: a receipt for a forward this host already answered synchronously — the CLI got the reply on the
|
|
44
|
+
* HTTP answer, so this copy is a duplicate. Kept in full (never dropped); readers may fold it.
|
|
45
|
+
*/
|
|
46
|
+
readonly dup?: 'sync-answered';
|
|
42
47
|
}
|
|
43
48
|
/** gh#113: one mailbox read — the rows plus what a reader needs to notice loss. */
|
|
44
49
|
export interface CliMailbox {
|
|
@@ -101,7 +106,7 @@ export declare class CliDepositStore {
|
|
|
101
106
|
description: string;
|
|
102
107
|
offline: boolean;
|
|
103
108
|
}>;
|
|
104
|
-
mail(handle: string, from: string, text: string): void;
|
|
109
|
+
mail(handle: string, from: string, text: string, dup?: 'sync-answered'): void;
|
|
105
110
|
readMail(handle: string): readonly CliMail[];
|
|
106
111
|
/** gh#113: the mailbox with its seq bounds and loss counter; `afterSeq` keeps only newer rows. */
|
|
107
112
|
readMailbox(handle: string, afterSeq?: number): CliMailbox;
|