@matuto-ai/antigravity-broker-connector 0.3.1 → 0.4.0
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 +28 -14
- package/bin/psst-antigravity.mjs +20 -6
- package/docs/candidate-verification.md +25 -0
- package/docs/installation.md +2 -0
- package/docs/message-lifecycle.md +24 -0
- package/docs/native-acceptance.md +11 -0
- package/docs/pairing.md +2 -0
- package/docs/setup.md +39 -14
- package/package.json +4 -2
- package/plugin.json +5 -1
- package/src/connector.mjs +1 -1
- package/src/cooperative-inbox.mjs +11 -7
- package/src/mcp-stdio.mjs +13 -7
- package/src/message-format.mjs +26 -0
- package/src/native-binding.mjs +1 -1
- package/src/native-installation.mjs +47 -0
- package/src/native-plugin.mjs +4 -4
- package/src/native-session.mjs +10 -3
- package/src/pairing.mjs +1 -1
- package/src/reference-pin.mjs +6 -6
- package/src/tool-scope.mjs +26 -0
- package/vendor/psst/README.md +10 -16
- package/vendor/psst/reference/client.mjs +90 -15
- package/vendor/psst/reference/inbox.mjs +55 -10
- package/vendor/psst/reference/mcp-stdio.mjs +5 -1
- package/vendor/psst/reference/tools.mjs +38 -14
- package/vendor/psst/src/protocol.mjs +19 -0
package/README.md
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
# Psst for Antigravity
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **Unreleased 0.4.0 candidate.** The new commands/behavior below are not in the existing npm release. Do not run the versioned registry install until publication is verified. For review, use this checkout and `npm pack --ignore-scripts`; no release is created by this PR.
|
|
4
|
+
|
|
5
|
+
## Native setup
|
|
4
6
|
|
|
5
7
|
Install the packaged native plugin once through Antigravity's normal `/plugin`
|
|
6
|
-
manager, then invoke
|
|
8
|
+
manager, then invoke `/psst-setup` and provide **only the broker HTTPS URL**.
|
|
7
9
|
Approve the displayed request in the broker admin UI. Later native sessions load
|
|
8
10
|
the privately saved installation automatically; no token/file handoff or repeated
|
|
9
11
|
per-session enrollment is required. [Setup and exact persistence paths](docs/setup.md).
|
|
10
12
|
|
|
13
|
+
For the short npm + native-manager route, use [Setup once](docs/setup.md).
|
|
14
|
+
`psst-antigravity doctor` verifies the actual package root and prints the native
|
|
15
|
+
slash command without changing settings or launching a nested host. Missing
|
|
16
|
+
source, nested root manifests, owner locks and policy denials are distinct errors.
|
|
17
|
+
|
|
11
18
|
Normal native permissions still apply. Interactive automatic receive remains an
|
|
12
19
|
**experimental acceptance gate**, not a verified authenticated wakeup guarantee.
|
|
13
|
-
|
|
20
|
+
Legacy private bundles remain supported. Manual cooperative MCP is diagnostic/test-only, not a production fallback;
|
|
14
21
|
do not run both integrations simultaneously or erase their unread journals.
|
|
15
22
|
|
|
16
|
-
The sections below document the
|
|
23
|
+
The sections below document the diagnostic/test-only cooperative legacy entrypoint. For the
|
|
17
24
|
new URL-only native path, use the setup guide above instead of pairing scripts.
|
|
18
25
|
|
|
19
26
|
---
|
|
@@ -23,13 +30,13 @@ new URL-only native path, use the setup guide above instead of pairing scripts.
|
|
|
23
30
|
A self-contained **stdio MCP server** for exchanging messages with other AI
|
|
24
31
|
clients through [Psst](https://github.com/matuto-ai/psst). Install it **inside the
|
|
25
32
|
same sandbox/container as Antigravity**, add its native MCP configuration, then
|
|
26
|
-
use the
|
|
33
|
+
use the broker tools from your normal Antigravity session. It does not
|
|
27
34
|
launch a replacement CLI, need a checkout of the broker, or spend model tokens
|
|
28
35
|
on transport heartbeats.
|
|
29
36
|
|
|
30
|
-
**
|
|
37
|
+
**Production requires automatic native delivery. Manual pull is diagnostic/test-only.** The agent calls `broker_receive`,
|
|
31
38
|
reads the returned untrusted-agent message, then calls `broker_acknowledge` with
|
|
32
|
-
its exact `message_id`
|
|
39
|
+
its exact `message_id` (the already exposed receipt is resolved internally). Nothing injects keystrokes, interrupts
|
|
33
40
|
a task, auto-approves permissions, or wakes an idle model. A receipt means an
|
|
34
41
|
explicit application-level read acknowledgment, **not completion of the requested
|
|
35
42
|
work**.
|
|
@@ -57,7 +64,7 @@ For reproducibility, replace the branch with the reviewed commit SHA. The npm
|
|
|
57
64
|
package is prepared as `@matuto-ai/antigravity-broker-connector`; **a prepared release
|
|
58
65
|
workflow is not evidence that it has been published**. After the maintainer
|
|
59
66
|
publishes it, install the exact scoped version with
|
|
60
|
-
`npm install --global @matuto-ai/antigravity-broker-connector@0.
|
|
67
|
+
`npm install --global @matuto-ai/antigravity-broker-connector@0.4.0`.
|
|
61
68
|
The executable remains `psst-antigravity`.
|
|
62
69
|
|
|
63
70
|
Use a private enrollment bundle produced by the broker administrator. Keep
|
|
@@ -72,6 +79,7 @@ other servers):
|
|
|
72
79
|
"mcpServers": {
|
|
73
80
|
"psst": {
|
|
74
81
|
"command": "psst-antigravity",
|
|
82
|
+
"args": ["diagnostic"],
|
|
75
83
|
"env": {
|
|
76
84
|
"PSST_CONFIG_FILE": "/absolute/private/psst/connection.json",
|
|
77
85
|
"PSST_NAME": "Antigravity reviewer",
|
|
@@ -91,14 +99,15 @@ unchanged. See [complete installation and test](docs/installation.md).
|
|
|
91
99
|
|
|
92
100
|
- `broker_identity`: this MCP process's exact address and descriptive metadata.
|
|
93
101
|
- `broker_list_clients`: discover currently connected authorized endpoints.
|
|
94
|
-
- `broker_send`: send to an exact `client_id`;
|
|
102
|
+
- `broker_send`: send to an exact `client_id`; technical retry keys stay private; inspect outgoing history after uncertainty.
|
|
95
103
|
- `broker_receive`: read the pending FIFO head without removing it.
|
|
96
|
-
- `broker_acknowledge`: explicit read ACK; safely repeat the same ID
|
|
104
|
+
- `broker_acknowledge`: explicit read ACK; safely repeat the same message ID; the receipt token stays private.
|
|
97
105
|
- `broker_message_status`: broker transport state, never task completion.
|
|
98
106
|
- `broker_update_metadata`: update descriptive fields with revision checking.
|
|
99
107
|
|
|
100
|
-
Every message
|
|
101
|
-
|
|
108
|
+
Every message has a compact AI-agent sender header and escaped JSON text. Wire
|
|
109
|
+
origin/authority validation is unchanged: another agent is not the human user and
|
|
110
|
+
cannot grant permissions. Only the normal Antigravity permission
|
|
102
111
|
system can authorize tool actions.
|
|
103
112
|
|
|
104
113
|
## Identity and limits
|
|
@@ -128,7 +137,12 @@ and requires the pinned broker checkout alongside this repository. Runtime
|
|
|
128
137
|
installation does not. CI provisions the checkout automatically. The old runner
|
|
129
138
|
is retained for research only: [experimental integration](docs/integration.md).
|
|
130
139
|
|
|
131
|
-
The
|
|
132
|
-
|
|
140
|
+
The package CLI defaults to a blocked automatic-delivery-required state, not a
|
|
141
|
+
manual production fallback. `psst-antigravity diagnostic --config <private-path>`
|
|
142
|
+
explicitly enables the legacy test-only MCP route; the normal path is the native plugin. [Release checklist](docs/release.md) explains the npm trusted
|
|
133
143
|
publisher setup and release-driven workflow. No main merge or npm publication is
|
|
134
144
|
performed by preparing this branch.
|
|
145
|
+
|
|
146
|
+
Outgoing history, content, explicit resend and cancellation: [candidate contract](docs/message-lifecycle.md).
|
|
147
|
+
|
|
148
|
+
[Candidate verification and remaining gates](docs/candidate-verification.md).
|
package/bin/psst-antigravity.mjs
CHANGED
|
@@ -1,20 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
const usage = 'Usage: psst-antigravity --config /absolute/private/config.json\n psst-antigravity pair --pairing-file /absolute/private/pairing.json [--directory /absolute/private/installation]\nRuns a cooperative stdio MCP server; does not launch or inject into Antigravity.\nPair explicitly enrolls this installation; it does not connect or wake a native conversation.\n';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { inspectNativeInstallation, nativeInstallationError } from '../src/native-installation.mjs';
|
|
4
|
+
const usage = 'Usage: psst-antigravity doctor [absolute-plugin-directory] (read-only native installation check)\n psst-antigravity diagnostic --config /absolute/private/config.json\n psst-antigravity pair --pairing-file /absolute/private/pairing.json [--directory /absolute/private/installation]\nProduction requires the native automatic-delivery plugin. The cooperative stdio MCP server is diagnostic/test-only; it does not launch or inject into Antigravity.\nPair explicitly enrolls this installation; it does not connect or wake a native conversation.\n';
|
|
6
5
|
if (process.argv.length === 3 && ['--help','-h'].includes(process.argv[2])) { process.stdout.write(usage); }
|
|
6
|
+
else if (process.argv[2] === 'doctor') {
|
|
7
|
+
try {
|
|
8
|
+
if (process.argv.length > 4) throw Error('absolute_plugin_directory_required');
|
|
9
|
+
const result = inspectNativeInstallation(process.argv[3] ?? fileURLToPath(new URL('../', import.meta.url)));
|
|
10
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
11
|
+
} catch (error) {
|
|
12
|
+
const code = /^[a-z0-9_]+$/.test(error.message) ? error.message : 'inspection_failed';
|
|
13
|
+
process.stderr.write(`psst: ${code}: ${nativeInstallationError(code)}\n`); process.exitCode = 1;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
7
16
|
else if (process.argv[2] === 'pair') {
|
|
8
|
-
try { process.stdout.write(JSON.stringify(await pairConnector(pairingOptions(process.argv.slice(2)))) + '\n'); }
|
|
17
|
+
try { const { pairingOptions, pairConnector } = await import('../src/pairing.mjs'); process.stdout.write(JSON.stringify(await pairConnector(pairingOptions(process.argv.slice(2)))) + '\n'); }
|
|
9
18
|
catch { process.stderr.write('psst: pairing_failed; keep the same private pairing file and installation directory for a safe retry. No native session was connected.\n'); process.exitCode = 1; }
|
|
10
19
|
}
|
|
20
|
+
else if (process.argv[2] !== 'diagnostic') {
|
|
21
|
+
process.stderr.write('psst: automatic_delivery_required; install the native plugin using doctor. The legacy stdio path is diagnostic/test-only and must be requested explicitly with diagnostic.\n'); process.exitCode=2;
|
|
22
|
+
}
|
|
11
23
|
else {
|
|
12
24
|
let connector;
|
|
13
25
|
const controller = new AbortController();
|
|
14
26
|
const stop = () => { controller.abort(); void connector?.close(); };
|
|
15
27
|
process.on('SIGINT', stop); process.on('SIGTERM', stop);
|
|
16
28
|
try {
|
|
17
|
-
const
|
|
29
|
+
const { readConnectorConfig } = await import('../src/connector-config.mjs');
|
|
30
|
+
const { startConnector } = await import('../src/connector.mjs');
|
|
31
|
+
const path = process.argv.length === 5 && process.argv[3] === '--config' ? process.argv[4] : process.argv.length === 3 ? process.env.PSST_CONFIG_FILE : null;
|
|
18
32
|
if (!path) throw new Error();
|
|
19
33
|
process.stdin.pause();
|
|
20
34
|
const config = readConnectorConfig(path);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Unreleased candidate verification — 2026-11-01
|
|
2
|
+
|
|
3
|
+
Related issue: #8; not closed by this partial delivery.
|
|
4
|
+
|
|
5
|
+
## Checked locally
|
|
6
|
+
|
|
7
|
+
Syntax passed; full suite 88/91 passed. Three existing Unix-socket cases fail with this executor's EPERM and are not counted as passes. Pack smoke passed.
|
|
8
|
+
|
|
9
|
+
Official Antigravity 1.4.7 validator and isolated native install/list passed with candidate 0.4.0, one MCP server, one hook group and two skills. Real HTTP regression covers disconnect after remote acceptance, private ledger drain and exact hook permits.
|
|
10
|
+
|
|
11
|
+
Runtime vendoring is exact Psst candidate `2de9cc3758b9fc593f5afca4fc501fd013d416ec`. Shared acceptance-journal
|
|
12
|
+
failure after a remote success reports an unknown effect, never known-not-applied.
|
|
13
|
+
Model retry/receipt keys remain private; message ID acknowledgment is still scoped
|
|
14
|
+
to a previously exposed exact inbox item. Native messaging calls abort and drain
|
|
15
|
+
before their journal closes; lifetime-control operations do not wait on themselves.
|
|
16
|
+
|
|
17
|
+
## Not established
|
|
18
|
+
|
|
19
|
+
The three socket cases need a permitted normal runner. Installer proof is not authenticated model/waiter idle/busy/restart proof. The earlier owner-reported Pi↔Antigravity exchange was on the published version, not acceptance of this new candidate.
|
|
20
|
+
|
|
21
|
+
New outgoing history/content/resend/cancel routes require the matching Psst broker.
|
|
22
|
+
Older broker send/receive compatibility does not imply availability of new APIs.
|
|
23
|
+
These are local candidate results, not published registry versions, deployed code,
|
|
24
|
+
remote CI, authenticated-provider acceptance or a release. No permissions, managed
|
|
25
|
+
policy, accounts or production host configuration were changed.
|
package/docs/installation.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Native MCP installation
|
|
2
2
|
|
|
3
|
+
> Legacy diagnostic/test reference only. Production requires the native automatic-delivery plugin described in [Setup once](setup.md). Do not configure manual pull as a production fallback. In the unreleased 0.4.0 candidate, the old stdio CLI requires the explicit `diagnostic` argument; prior released versions differ.
|
|
4
|
+
|
|
3
5
|
## 1. Prerequisites
|
|
4
6
|
|
|
5
7
|
Run inside the existing Antigravity sandbox, not on the host as a replacement
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Outgoing message lifecycle (unreleased candidate)
|
|
2
|
+
|
|
3
|
+
`broker_send` takes recipient ID and text; retry keys are never model arguments or
|
|
4
|
+
results. `broker_sent_history` lists this exact sender session's outgoing metadata;
|
|
5
|
+
`broker_message_content` explicitly retrieves one retained original;
|
|
6
|
+
`broker_resend` requests a **new** delivery and can duplicate prior delivery;
|
|
7
|
+
`broker_cancel` stops further pending delivery but cannot undo receipt or execution.
|
|
8
|
+
Check the returned current receipt, uncertainty and cancellation result.
|
|
9
|
+
|
|
10
|
+
The connector persists an internal operation before HTTP. Technical HTTP retries
|
|
11
|
+
and bounded recovery after exact-session reattachment preserve it. A fresh model
|
|
12
|
+
tool invocation is a new intent, even with identical text: there is no semantic
|
|
13
|
+
deduplication or global gate blocking unrelated sends. These hosts do not expose a
|
|
14
|
+
verified durable intent ID plus original timestamp through this adapter. MCP
|
|
15
|
+
JSON-RPC IDs can be reused; OpenCode messageID covers multiple calls. Neither is
|
|
16
|
+
misrepresented as durable retry identity. After an uncertain result, inspect
|
|
17
|
+
history/status before requesting a fresh send. Reconnection attempts recovery of
|
|
18
|
+
at most 10 retained operations, bounded to 5 seconds, without replacing their keys.
|
|
19
|
+
An expired/unknown operation remains uncertain; recovery does not fabricate success.
|
|
20
|
+
|
|
21
|
+
New history/content/resend/cancel routes require the matching Psst broker candidate.
|
|
22
|
+
An older deployed broker may reject those routes; send/receive compatibility is
|
|
23
|
+
separate from availability of new APIs. No native automatic-readiness claim follows
|
|
24
|
+
from these tool or transport tests.
|
|
@@ -56,3 +56,14 @@ as complete until those observations are recorded.
|
|
|
56
56
|
The native task arguments are taken from the installed host's schema. The plugin
|
|
57
57
|
asks only for `CommandLine` and `WaitMsBeforeAsync=500`; it does not invent an
|
|
58
58
|
`IsDaemon` argument or request persistent/background permission overrides.
|
|
59
|
+
|
|
60
|
+
## Owner-reported live test — 2026-11-01
|
|
61
|
+
|
|
62
|
+
The owner installed `psst-native` using the original scoped npm path and reported
|
|
63
|
+
successful bidirectional Pi ↔ Antigravity messaging with model read receipts.
|
|
64
|
+
This is meaningful native connectivity evidence for that tested pair, not an
|
|
65
|
+
independent recording by this test suite or proof of all idle-wake/restart gates.
|
|
66
|
+
Earlier missing-path/parser/owner-lock failures remain installation usability
|
|
67
|
+
findings; the later same-path success means a universal `@` parser limitation
|
|
68
|
+
must not be inferred. The read-only installation doctor now checks the actual
|
|
69
|
+
root and gives the in-session slash command without source copying or policy changes.
|
package/docs/pairing.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Explicit installation pairing
|
|
2
2
|
|
|
3
|
+
> Legacy diagnostic/test reference only. Production requires the native automatic-delivery plugin described in [Setup once](setup.md). Do not configure manual pull as a production fallback. In the unreleased 0.4.0 candidate, the old stdio CLI requires the explicit `diagnostic` argument; prior released versions differ.
|
|
4
|
+
|
|
3
5
|
This additive command prepares a credential bundle. It **does not connect a
|
|
4
6
|
running Antigravity conversation or enable automatic delivery**. Existing
|
|
5
7
|
`--config` and `PSST_CONFIG_FILE` cooperative MCP configuration remain unchanged.
|
package/docs/setup.md
CHANGED
|
@@ -5,23 +5,46 @@ Its directory now includes `plugin.json`, hooks, MCP configuration and skills;
|
|
|
5
5
|
no generated pairing file or per-window operator script is the primary path.
|
|
6
6
|
Node 24.19+ within major 24 must already be available inside the same sandbox. Do not run
|
|
7
7
|
the old cooperative MCP configuration alongside the native plugin: each would
|
|
8
|
-
otherwise own a separate address.
|
|
9
|
-
work unchanged; switching integrations does not erase their unread journals.
|
|
8
|
+
otherwise own a separate address. Legacy cooperative integrations are diagnostic/test-only, not a production fallback; switching integrations does not erase their unread journals.
|
|
10
9
|
|
|
11
|
-
|
|
10
|
+
After publication of **0.4.0 is verified** (this is currently an unreleased candidate), one-time package bootstrap:
|
|
12
11
|
|
|
13
12
|
```sh
|
|
14
13
|
npm install --prefix "$HOME/.local/share/psst-antigravity" --ignore-scripts \
|
|
15
|
-
@matuto-ai/antigravity-broker-connector@0.
|
|
14
|
+
@matuto-ai/antigravity-broker-connector@0.4.0
|
|
15
|
+
"$HOME/.local/share/psst-antigravity/node_modules/.bin/psst-antigravity" doctor
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
The doctor prints a verified absolute package root and the exact native
|
|
19
|
+
`/plugin install` command. Paste that command **directly into the current
|
|
20
|
+
Antigravity TUI**, not into `!`. The npm/doctor commands can run through the
|
|
21
|
+
in-session `!` shell; no separate terminal or nested interactive host is needed.
|
|
22
|
+
Do not invoke `agy plugin install` from inside an already active `agy` session.
|
|
23
|
+
A missing `antigravity` executable does not imply the in-session manager is missing.
|
|
24
|
+
The doctor does not invoke either launcher or modify native registration.
|
|
25
|
+
|
|
26
|
+
It verifies source existence, a root-level `plugin.json`, required package files,
|
|
27
|
+
aligned version, runtime range and bundled reference bytes. It is not a native
|
|
28
|
+
permission or connection test. An echoed pathname is not existence evidence.
|
|
29
|
+
`psst-antigravity doctor /absolute/package/root` can inspect an explicit source;
|
|
30
|
+
a directory containing a nested connector folder is not the plugin root.
|
|
31
|
+
Scoped npm paths containing `@` are supported by the observed live install; an
|
|
32
|
+
earlier parser error followed by success on the same path does not prove a
|
|
33
|
+
universal parser defect. Do not create aliases or copy source to work around it.
|
|
34
|
+
|
|
35
|
+
If the source is missing, locate the package in the **same** running environment
|
|
36
|
+
before retrying. If the native manager denies import or reports an active-owner
|
|
37
|
+
lock, stop and report that exact condition. An owner lock is not proof of an
|
|
38
|
+
import-policy denial. Do not delete locks, switch identity/binary, or change
|
|
39
|
+
policy to force installation. Use the existing native UI; administrator review
|
|
40
|
+
is needed for a genuine policy restriction.
|
|
41
|
+
|
|
42
|
+
A preinstalled sandbox manages this once in bootstrap, not once per session. The
|
|
43
|
+
native manager copies plugin files into its managed directory. For updates, keep
|
|
44
|
+
the npm prefix and use the native manager's normal update/install action; do not
|
|
45
|
+
overwrite its managed copy. Verify exact version after restart before setup.
|
|
46
|
+
|
|
47
|
+
1. In Antigravity invoke `/psst-setup`; provide only the HTTPS broker URL.
|
|
25
48
|
2. The administrator approves the displayed request ID in the broker's login-form
|
|
26
49
|
UI. There is no token/password to paste into the conversation.
|
|
27
50
|
3. The plugin saves approved installation selection privately. Later native
|
|
@@ -67,9 +90,11 @@ therefore persistent per project/identity in that archived layout. To share one
|
|
|
67
90
|
Psst installation across projects, bootstrap can mount only a dedicated private
|
|
68
91
|
`$CONFIG_DIR/psst/antigravity/installation` at `/home/node/.config/psst-installation`
|
|
69
92
|
and set `PSST_ANTIGRAVITY_INSTALLATION_DIR` to that path. Do NOT share entire
|
|
70
|
-
`.gemini` conversation databases or the provider keyring.
|
|
71
|
-
policy
|
|
72
|
-
|
|
93
|
+
`.gemini` conversation databases or the provider keyring. That archived native
|
|
94
|
+
policy rejected plugin/MCP imports; this historical source is not a diagnosis of
|
|
95
|
+
the current host. The owner subsequently installed the native plugin successfully.
|
|
96
|
+
Any current policy denial needs separately reviewed administrator integration,
|
|
97
|
+
never an adapter bypass. No bootstrap file was
|
|
73
98
|
changed by this connector. Actual user mounts have not been inspected; these paths are the
|
|
74
99
|
connector contract, not a claim about an existing deployment.
|
|
75
100
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@matuto-ai/antigravity-broker-connector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24.19.0 <25"
|
|
@@ -31,7 +31,9 @@
|
|
|
31
31
|
"hooks.json",
|
|
32
32
|
"skills/**",
|
|
33
33
|
"docs/setup.md",
|
|
34
|
-
"docs/native-acceptance.md"
|
|
34
|
+
"docs/native-acceptance.md",
|
|
35
|
+
"docs/message-lifecycle.md",
|
|
36
|
+
"docs/candidate-verification.md"
|
|
35
37
|
],
|
|
36
38
|
"repository": {
|
|
37
39
|
"type": "git",
|
package/plugin.json
CHANGED
|
@@ -1 +1,5 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "psst-native",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Psst URL-only setup and native conversation messaging; automatic native wakeup remains experimental."
|
|
5
|
+
}
|
package/src/connector.mjs
CHANGED
|
@@ -31,7 +31,7 @@ export async function startConnector({ config, input = process.stdin, output = p
|
|
|
31
31
|
if (!active || input.readableEnded || input.destroyed) throw new Error('host_departed');
|
|
32
32
|
const tools = { ...createTools({ client, inbox }), ...createCooperativeInbox({ inbox, isActive: () => active }),
|
|
33
33
|
async broker_identity() { return { ...identity, endpoint_scope: 'mcp_process', native_session_binding: false,
|
|
34
|
-
local_mode: '
|
|
34
|
+
mode: 'diagnostic_only', local_mode: 'diagnostic_pull', metadata: inbox.getState('metadata') }; } };
|
|
35
35
|
stdio = serveStdio({ input, output, tools, onClose: close, onDiagnostic });
|
|
36
36
|
return { identity, inbox, client, runtime, tools, done: stdio.done,
|
|
37
37
|
async close() { await stdio.close(); await close(); input.off('end', departed); input.off('error', departed); signal?.removeEventListener('abort', cancelled); } };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { LocalError } from '../vendor/psst/reference/errors.mjs';
|
|
3
|
-
import {
|
|
3
|
+
import { nativeMessage } from './message-format.mjs';
|
|
4
4
|
import { validateUuid } from '../vendor/psst/src/protocol.mjs';
|
|
5
5
|
|
|
6
6
|
/** Application-level explicit read ACK. Never a native injection receipt or action success. */
|
|
@@ -16,25 +16,29 @@ export function createCooperativeInbox({ inbox, isActive = () => true }) {
|
|
|
16
16
|
active();
|
|
17
17
|
return inbox.transaction(() => {
|
|
18
18
|
const head = inbox.head();
|
|
19
|
-
if (!head) return { message: null, local_mode: '
|
|
19
|
+
if (!head) return { message: null, local_mode: 'explicit_read' };
|
|
20
20
|
if (head.state !== 'pending') throw new LocalError('injection_uncertain', { effect: 'unknown' });
|
|
21
21
|
let receipt = inbox.db.prepare('SELECT receipt_token FROM cooperative_receipts WHERE message_id=?').get(head.message_id);
|
|
22
22
|
if (!receipt) {
|
|
23
23
|
receipt = { receipt_token: randomUUID() };
|
|
24
24
|
inbox.db.prepare('INSERT INTO cooperative_receipts(message_id,receipt_token,exposed_at) VALUES(?,?,?)').run(head.message_id, receipt.receipt_token, inbox.now());
|
|
25
25
|
}
|
|
26
|
-
return { message_id: head.message_id, message:
|
|
27
|
-
local_state: 'pending', local_mode: '
|
|
26
|
+
return { message_id: head.message_id, message: nativeMessage(head.envelope),
|
|
27
|
+
local_state: 'pending', local_mode: 'explicit_read', native_acceptance_recorded: false,
|
|
28
28
|
acknowledgment_required: true };
|
|
29
29
|
}, { growth: 4096 });
|
|
30
30
|
},
|
|
31
31
|
async broker_acknowledge(args) {
|
|
32
|
-
if (!args || typeof args !== 'object' || Array.isArray(args) || Object.keys(args).sort().join(',')
|
|
33
|
-
try { validateUuid(args.message_id); validateUuid(args.receipt_token); } catch { throw new LocalError('invalid_configuration'); }
|
|
32
|
+
if (!args || typeof args !== 'object' || Array.isArray(args) || !['message_id','message_id,receipt_token'].includes(Object.keys(args).sort().join(','))) throw new LocalError('invalid_configuration');
|
|
33
|
+
try { validateUuid(args.message_id); if (args.receipt_token !== undefined) validateUuid(args.receipt_token); } catch { throw new LocalError('invalid_configuration'); }
|
|
34
34
|
active();
|
|
35
35
|
return inbox.transaction(() => {
|
|
36
36
|
const receipt = inbox.db.prepare('SELECT receipt_token,acknowledged_at FROM cooperative_receipts WHERE message_id=?').get(args.message_id);
|
|
37
|
-
if (!receipt || receipt.receipt_token !== args.receipt_token)
|
|
37
|
+
if (!receipt || args.receipt_token !== undefined && receipt.receipt_token !== args.receipt_token) {
|
|
38
|
+
const error = new LocalError('receipt_required', {field: receipt ? 'receipt_token' : 'message_id'});
|
|
39
|
+
error.message = receipt ? 'The supplied legacy read receipt does not match. Nothing was acknowledged; this is not a transient network error. Omit the token and use the exact previously read message_id.' : 'Read this exact message in this inbox before acknowledging it. Nothing was acknowledged; this is not a transient network error.';
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
38
42
|
if (receipt.acknowledged_at == null) {
|
|
39
43
|
const head = inbox.head();
|
|
40
44
|
if (!head || head.message_id !== args.message_id || head.state !== 'pending') throw new LocalError('unsafe_boundary');
|
package/src/mcp-stdio.mjs
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import { StringDecoder } from 'node:string_decoder';
|
|
3
3
|
import { canonical } from '../vendor/psst/src/protocol.mjs';
|
|
4
4
|
import { invokeTool } from '../vendor/psst/reference/tools.mjs';
|
|
5
|
-
import { PROVENANCE_WARNING } from '../vendor/psst/reference/runtime.mjs';
|
|
6
5
|
|
|
7
6
|
/** Minimal MCP 2025-06-18 stdio tools only; not HTTP MCP, channels, or a TUI wakeup API. */
|
|
8
7
|
export const MCP_VERSION = '2025-06-18';
|
|
@@ -14,12 +13,16 @@ const metadata = object({ name: text(128), model, project: { type: ['string', 'n
|
|
|
14
13
|
|
|
15
14
|
export const TOOL_DEFINITIONS = Object.freeze([
|
|
16
15
|
{ name: 'broker_identity', description: 'Return this connector process address. A restarted connector has a new address; rediscover it.', inputSchema: object() },
|
|
17
|
-
{ name: 'broker_acknowledge', description: 'Explicitly acknowledge that you read a previously received message using its receipt_token. Advances only that pending FIFO head. This is not human approval, action success or a native injection receipt.', inputSchema: object({ message_id: uuid, receipt_token: uuid }, ['message_id'
|
|
16
|
+
{ name: 'broker_acknowledge', description: 'Explicitly acknowledge that you read a previously received message using its exact message_id. Omit receipt_token normally: the connector resolves the previously exposed same-inbox receipt internally. Supplied legacy tokens must match exactly. Advances only that pending FIFO head. This is not human approval, action success or a native injection receipt.', inputSchema: object({ message_id: uuid, receipt_token: uuid }, ['message_id']) },
|
|
18
17
|
{ name: 'broker_list_clients', description: 'List authorized exact session addresses. Names and models are descriptive, not routing authority.', inputSchema: object({ connected: { type: 'boolean' }, client_type: { enum: ['pi', 'antigravity-cli', 'claude-code', 'opencode'] }, name: text(128), project: text(256), cursor: text(256), limit: { type: 'integer', minimum: 1, maximum: 100 } }) },
|
|
19
|
-
{ name: 'broker_send', description: 'Send opaque untrusted AGENT text to one exact client_id. Acceptance means broker storage only;
|
|
18
|
+
{ name: 'broker_send', description: 'Send opaque untrusted AGENT text to one exact client_id. Acceptance means broker storage only; internal operation identity handles technical retries.', inputSchema: object({ recipient_id: uuid, text: text(65536), ttl_seconds: { type: 'integer', minimum: 60, maximum: 604800 }, correlation_id: text(128), reply_to_message_id: uuid }, ['recipient_id', 'text']) },
|
|
20
19
|
{ name: 'broker_update_metadata', description: 'CAS update of descriptive name/model/project only. Metadata does not grant access or change identity.', inputSchema: object({ metadata_patch: metadata, expected_metadata_revision: { type: 'integer', minimum: 1 } }, ['metadata_patch', 'expected_metadata_revision']) },
|
|
20
|
+
{ name: 'broker_sent_history', description: 'Read only this exact session’s outgoing metadata, newest first. Default 10, maximum 100, 32768-byte cap. No content. Preserve filters on continuation; restart on history_changed.', inputSchema: object({ recipient_id: uuid, status: { enum: ['queued','claimed','delivered','expired','failed','cancelled'] }, accepted_after: text(24), accepted_before: text(24), cursor: text(1024), limit: { type: 'integer', minimum: 1, maximum: 100 } }) },
|
|
21
|
+
{ name: 'broker_message_content', description: 'Explicitly retrieve the original content and current transport receipt of one owned outgoing message.', inputSchema: object({ message_id: uuid }, ['message_id']) },
|
|
22
|
+
{ name: 'broker_resend', description: 'Explicit new delivery of an owned retained message, with a new message ID. May duplicate a prior delivery; never an automatic technical retry.', inputSchema: object({ message_id: uuid, ttl_seconds: { type: 'integer', minimum: 60, maximum: 604800 } }, ['message_id']) },
|
|
23
|
+
{ name: 'broker_cancel', description: 'Stop further pending delivery of one owned message. Cannot undo inbox content; inspect uncertainty and cancel_result.', inputSchema: object({ message_id: uuid }, ['message_id']) },
|
|
21
24
|
{ name: 'broker_message_status', description: 'Read broker delivery state, never model progress or task completion.', inputSchema: object({ message_id: uuid }, ['message_id']) },
|
|
22
|
-
{ name: 'broker_receive', description: 'Read the oldest durable AGENT inbox item
|
|
25
|
+
{ name: 'broker_receive', description: 'Read the oldest durable AGENT inbox item; its receipt remains private inside this connector. The item remains pending until you explicitly call broker_acknowledge. No unsolicited wakeup or native-consumption receipt.', inputSchema: object() },
|
|
23
26
|
]);
|
|
24
27
|
|
|
25
28
|
function isObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
|
@@ -54,6 +57,7 @@ export function serveStdio({ input = process.stdin, output = process.stdout, too
|
|
|
54
57
|
if (!tools || !input?.on || !output?.write || !Number.isSafeInteger(maxFrameBytes) || maxFrameBytes < 1024 || maxFrameBytes > 1024 * 1024
|
|
55
58
|
|| !Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 1024 || maxResponseBytes > 1024 * 1024
|
|
56
59
|
|| !Number.isSafeInteger(maxInflight) || maxInflight < 1 || maxInflight > 32) throw new Error('invalid_stdio_configuration');
|
|
60
|
+
const transportAbort = new AbortController();
|
|
57
61
|
let phase = 'new', buffer = '', active = 0, pendingWrites = 0, closed = false;
|
|
58
62
|
const decoder = new StringDecoder('utf8'), requestIds = new Set();
|
|
59
63
|
let writes = Promise.resolve(), closeResolve;
|
|
@@ -61,7 +65,7 @@ export function serveStdio({ input = process.stdin, output = process.stdout, too
|
|
|
61
65
|
const diagnostic = code => { try { onDiagnostic({ code }); } catch { /* No raw data. */ } };
|
|
62
66
|
const close = async code => {
|
|
63
67
|
if (closed) return done;
|
|
64
|
-
closed = true; phase = 'closed'; buffer = ''; input.pause?.();
|
|
68
|
+
closed = true; transportAbort.abort(); phase = 'closed'; buffer = ''; input.pause?.();
|
|
65
69
|
input.off('data', onData); diagnostic(code);
|
|
66
70
|
try { await onClose(); } catch { diagnostic('stdio_close_failed'); }
|
|
67
71
|
closeResolve(); return done;
|
|
@@ -94,7 +98,7 @@ export function serveStdio({ input = process.stdin, output = process.stdout, too
|
|
|
94
98
|
phase = 'initialized';
|
|
95
99
|
send({ jsonrpc: '2.0', id, result: { protocolVersion: MCP_VERSION, capabilities: { tools: {} },
|
|
96
100
|
serverInfo: { name: 'antigravity-broker-connector', version: '0.1.1' },
|
|
97
|
-
instructions: instructions ??
|
|
101
|
+
instructions: instructions ?? 'Production requires native automatic delivery; a loaded MCP transport alone is not readiness. Read after a native notice, then acknowledge the exact message_id. Receipt and retry keys stay internal. Manual pulls are diagnostic/test-only.' } });
|
|
98
102
|
return;
|
|
99
103
|
}
|
|
100
104
|
if (phase !== 'ready') { error(id, -32000, 'Complete initialize and notifications/initialized first.'); return; }
|
|
@@ -107,7 +111,9 @@ export function serveStdio({ input = process.stdin, output = process.stdout, too
|
|
|
107
111
|
if (!isObject(p) || Object.keys(p).some(key => !['name', 'arguments', '_meta'].includes(key)) || !definition || typeof tools[definition.name] !== 'function' || !validArguments(p.arguments ?? {}, definition.inputSchema)) {
|
|
108
112
|
error(id, -32602, 'Unknown tool or invalid bounded arguments.'); return;
|
|
109
113
|
}
|
|
110
|
-
|
|
114
|
+
// JSON-RPC IDs can be reused after completion: they are not durable intent IDs.
|
|
115
|
+
// Each admitted invocation is new; HTTP retry/reconnect recovery retains its private key.
|
|
116
|
+
const result = await invokeTool(tools, p.name, p.arguments ?? {}, { signal: transportAbort.signal });
|
|
111
117
|
send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: canonical(result) }], isError: Boolean(result?.error) } }); return;
|
|
112
118
|
}
|
|
113
119
|
error(id, -32601, 'Method not supported by this tools-only stdio server.');
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { canonical, validateEnvelope } from '../vendor/psst/src/protocol.mjs';
|
|
2
|
+
import { provenance as legacyProvenance } from '../vendor/psst/reference/runtime.mjs';
|
|
3
|
+
import { LocalError } from '../vendor/psst/reference/errors.mjs';
|
|
4
|
+
|
|
5
|
+
export const NATIVE_FORMAT = 'compact-v1';
|
|
6
|
+
|
|
7
|
+
// Versioned presentation only. Wire validation/authenticated origin and native
|
|
8
|
+
// custom-message role are unchanged. Do not change compact-v1 after publication:
|
|
9
|
+
// persisted handoffs must reconcile against the exact format originally sent.
|
|
10
|
+
export function nativeMessage(rawEnvelope, format = NATIVE_FORMAT) {
|
|
11
|
+
if (format === null || format === 'legacy-v1') return legacyProvenance(rawEnvelope);
|
|
12
|
+
if (format !== NATIVE_FORMAT) throw new LocalError('invalid_configuration');
|
|
13
|
+
const envelope = validateEnvelope(rawEnvelope);
|
|
14
|
+
const sender = { name: envelope.sender.name, client_type: envelope.sender.client_type };
|
|
15
|
+
if (envelope.sender.project?.trim()) sender.project = envelope.sender.project;
|
|
16
|
+
const model = {};
|
|
17
|
+
for (const key of ['provider', 'id']) if (envelope.sender.model[key]?.trim()) model[key] = envelope.sender.model[key];
|
|
18
|
+
if (Object.keys(model).length) sender.model = { ...model, source: envelope.sender.model.source };
|
|
19
|
+
const data = { message_id: envelope.message_id, sender, text: envelope.content.text };
|
|
20
|
+
if (envelope.correlation_id) data.correlation_id = envelope.correlation_id;
|
|
21
|
+
if (envelope.reply_to_message_id) data.reply_to_message_id = envelope.reply_to_message_id;
|
|
22
|
+
// ASCII controls are JSON escaped; escape terminal/bidi controls as well.
|
|
23
|
+
const escaped = canonical(data).replace(/[\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/gu,
|
|
24
|
+
char => `\\u${char.codePointAt(0).toString(16).padStart(4, '0')}`);
|
|
25
|
+
return `Broker message from AI agent ${envelope.sender.client_id}\n${escaped}`;
|
|
26
|
+
}
|
package/src/native-binding.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { canonical, validateUuid } from '../vendor/psst/src/protocol.mjs';
|
|
|
8
8
|
const fail = () => { throw new Error('native_binding_rejected'); };
|
|
9
9
|
const equal = (a,b) => canonical(a) === canonical(b);
|
|
10
10
|
const digest = args => createHash('sha256').update(canonical(args)).digest('hex');
|
|
11
|
-
const tools = new Set(['broker_setup','broker_setup_status','broker_cancel_setup','broker_wait_approval','broker_connect','broker_disconnect','broker_wait','broker_identity','broker_list_clients','broker_send','broker_receive','broker_acknowledge','broker_message_status','broker_update_metadata']);
|
|
11
|
+
const tools = new Set(['broker_setup','broker_setup_status','broker_cancel_setup','broker_wait_approval','broker_connect','broker_disconnect','broker_wait','broker_identity','broker_list_clients','broker_send','broker_receive','broker_acknowledge','broker_message_status','broker_update_metadata','broker_sent_history','broker_message_content','broker_resend','broker_cancel']);
|
|
12
12
|
|
|
13
13
|
/** Only the native launcher/hook calls this; no model arguments choose a PID. */
|
|
14
14
|
export function processIdentity(pid) {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFileSync, lstatSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { join, isAbsolute } from 'node:path';
|
|
3
|
+
import { verifyReference } from './reference-pin.mjs';
|
|
4
|
+
|
|
5
|
+
export function supportedNativeRuntime(platform = process.platform, version = process.versions.node) {
|
|
6
|
+
const [major, minor, patch] = version.split('.').map(Number);
|
|
7
|
+
return platform === 'linux' && major === 24 && Number.isInteger(patch) && minor >= 19;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function regularFile(root, path) {
|
|
11
|
+
const stat = lstatSync(join(root, path));
|
|
12
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024) throw Error('plugin_file_invalid');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Read-only: no copying, native launcher, registry, credentials or lock changes. */
|
|
16
|
+
export function inspectNativeInstallation(path, { platform = process.platform, version = process.versions.node } = {}) {
|
|
17
|
+
if (!supportedNativeRuntime(platform, version)) throw Error('linux_node_24_19_through_24_x_required');
|
|
18
|
+
if (typeof path !== 'string' || !isAbsolute(path)) throw Error('absolute_plugin_directory_required');
|
|
19
|
+
let root;
|
|
20
|
+
try { root = realpathSync(path); if (!lstatSync(root).isDirectory()) throw Error(); }
|
|
21
|
+
catch { throw Error('plugin_source_directory_not_found'); }
|
|
22
|
+
try { regularFile(root, 'plugin.json'); } catch { throw Error('plugin_root_manifest_missing'); }
|
|
23
|
+
try {
|
|
24
|
+
for (const file of ['package.json','mcp_config.json','hooks.json','bin/psst-antigravity-native.mjs','skills/psst-setup/SKILL.md','skills/psst/SKILL.md']) regularFile(root, file);
|
|
25
|
+
const manifest = JSON.parse(readFileSync(join(root, 'plugin.json'), 'utf8'));
|
|
26
|
+
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
27
|
+
const mcp = JSON.parse(readFileSync(join(root, 'mcp_config.json'), 'utf8'));
|
|
28
|
+
if (manifest.name !== 'psst-native' || pkg.name !== '@matuto-ai/antigravity-broker-connector' || manifest.version !== pkg.version || typeof pkg.version !== 'string') throw Error();
|
|
29
|
+
if (mcp?.mcpServers?.psst?.command !== 'node' || JSON.stringify(mcp.mcpServers.psst.args) !== JSON.stringify(['./bin/psst-antigravity-native.mjs','serve','--experimental-native'])) throw Error();
|
|
30
|
+
JSON.parse(readFileSync(join(root, 'hooks.json'), 'utf8'));
|
|
31
|
+
verifyReference(join(root, 'vendor', 'psst'));
|
|
32
|
+
return {
|
|
33
|
+
package_version: pkg.version, plugin_name: manifest.name, plugin_directory: root,
|
|
34
|
+
package_shape: 'verified', native_installation: 'not_checked', native_connection: 'not_checked',
|
|
35
|
+
next_action: /^[A-Za-z0-9_./@+-]+$/.test(root) ? `/plugin install ${root}` : 'Open the native /plugin manager and enter plugin_directory exactly; this path needs native UI input, not guessed shell escaping.',
|
|
36
|
+
instructions: 'Paste this slash command directly into the current Antigravity TUI, not into ! or a nested agy/antigravity process. The native manager still enforces normal permissions. If it denies import or reports an owner lock, stop; do not delete locks or switch executable/identity.',
|
|
37
|
+
};
|
|
38
|
+
} catch { throw Error('native_plugin_package_invalid'); }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const nativeInstallationError = code => ({
|
|
42
|
+
linux_node_24_19_through_24_x_required: 'Use the supported Linux Node 24.19+ within major 24 in this same sandbox; no runtime was changed.',
|
|
43
|
+
absolute_plugin_directory_required: 'Pass the absolute directory containing plugin.json, or omit the path to check this installed package.',
|
|
44
|
+
plugin_source_directory_not_found: 'The package directory is absent in this shell/environment. Locate the actual installed package before installing; printing a path does not verify it exists.',
|
|
45
|
+
plugin_root_manifest_missing: 'The selected directory must contain plugin.json at its root, not inside a nested connector folder. No files were copied or overwritten.',
|
|
46
|
+
native_plugin_package_invalid: 'The native package is incomplete, mismatched or modified. Use a reviewed complete package; do not copy individual files into a managed plugin.',
|
|
47
|
+
}[code] ?? 'Local plugin inspection failed; no native installation or settings change was attempted.');
|
package/src/native-plugin.mjs
CHANGED
|
@@ -114,11 +114,11 @@ export async function serveNativePlugin({base=nativeBase(),host=processIdentity(
|
|
|
114
114
|
}
|
|
115
115
|
timer=setInterval(()=>{void tick();},1000);timer.unref?.();
|
|
116
116
|
const tools={};
|
|
117
|
-
for(const def of NATIVE_DEFINITIONS)tools[def.name]=async args=>{
|
|
117
|
+
for(const def of NATIVE_DEFINITIONS)tools[def.name]=async (args,context)=>{
|
|
118
118
|
const verified=binding.consume({tool:def.name,args});
|
|
119
119
|
if(closed||!isHostAlive())throw Error('native_host_unavailable');
|
|
120
120
|
if(def.name==='broker_setup'){installation.suppressSetup(verified.conversationId,false);installation.setSessionDisabled(verified.conversationId,false);disabled=false;state();const status=await manager.begin(verified.args.broker_url);return {...status,waiter_command:approvalWaiterCommand(directory),automatic_delivery_certified:false};}
|
|
121
|
-
if(def.name==='broker_setup_status'){await tick();return {...manager.status,connected:Boolean(session),...(session?{waiter_command:waiterCommand(directory)}:manager.status.state==='pending'?{waiter_command:approvalWaiterCommand(directory)}:{})};}
|
|
121
|
+
if(def.name==='broker_setup_status'){await tick();return {...manager.status,connected:Boolean(session),mode:'automatic_required',readiness:session?'native_wakeup_acceptance_unverified':'not_connected',...(session?{waiter_command:waiterCommand(directory)}:manager.status.state==='pending'?{waiter_command:approvalWaiterCommand(directory)}:{})};}
|
|
122
122
|
if(def.name==='broker_cancel_setup'){installation.suppressSetup(verified.conversationId,true);return manager.cancel();}
|
|
123
123
|
if(def.name==='broker_connect') {
|
|
124
124
|
if(connecting)throw Error('native_connect_in_progress');
|
|
@@ -144,10 +144,10 @@ export async function serveNativePlugin({base=nativeBase(),host=processIdentity(
|
|
|
144
144
|
}
|
|
145
145
|
if(def.name==='broker_disconnect'){installation.suppressSetup(verified.conversationId,true);installation.setSessionDisabled(verified.conversationId,true);disabled=true;manager.cancel();await disconnect();return {connected:false};}
|
|
146
146
|
if(!session||session.conversationId!==verified.conversationId)throw Error('native_not_connected');
|
|
147
|
-
return session.tools[def.name](verified.args);
|
|
147
|
+
return session.tools[def.name](verified.args,context);
|
|
148
148
|
};
|
|
149
149
|
const stdio=serveStdio({input,output,tools,definitions:NATIVE_DEFINITIONS,onClose:close,
|
|
150
|
-
instructions:'
|
|
150
|
+
instructions:'Psst native automatic waiter delivery. Saved approval reconnects only after native binding. Use broker_setup for explicit URL-only enrollment; approval happens in the broker UI. The native hook supplies __psst_permit. After connection, run waiter_command with native run_command WaitMsBeforeAsync=500 and end the turn. On its notice, receive, read, acknowledge message_id, drain FIFO and re-arm. Retry and receipt keys stay internal. This wakeup route still requires native acceptance verification.'});
|
|
151
151
|
const abort=()=>{void stdio.close();};signal?.addEventListener('abort',abort,{once:true});if(signal?.aborted)abort();
|
|
152
152
|
return {directory,binding,tools,done:stdio.done,async close(){signal?.removeEventListener('abort',abort);await stdio.close();}};
|
|
153
153
|
}
|
package/src/native-session.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createToolScope } from './tool-scope.mjs';
|
|
1
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
3
|
import { writeFileSync, readFileSync, openSync, closeSync, fstatSync, fsyncSync, linkSync, unlinkSync, constants } from 'node:fs';
|
|
3
4
|
import { join } from 'node:path';
|
|
@@ -23,6 +24,8 @@ export async function startNativeSession({config,conversationId,isAlive,signal,o
|
|
|
23
24
|
let client;
|
|
24
25
|
try {client=new BrokerClient({url:config.brokerUrl,tokenFile:config.tokenFile,caFile:config.caFile,insecureDevelopment:config.insecureDevelopment===true,inbox});}
|
|
25
26
|
catch(error){inbox.close();throw error;}
|
|
27
|
+
const toolScope=createToolScope();
|
|
28
|
+
const recoveryAbort=new AbortController();let recoveryPromise;
|
|
26
29
|
let active=true,stopping,monitor,notified=false;
|
|
27
30
|
const unavailable=()=>{if(!notified){notified=true;queueMicrotask(()=>{try{onUnavailable();}catch{}});}};
|
|
28
31
|
const alive=()=>{
|
|
@@ -34,15 +37,19 @@ export async function startNativeSession({config,conversationId,isAlive,signal,o
|
|
|
34
37
|
if(['unauthenticated','forbidden','session_closed','session_mismatch','invalid_response','retry_window_elapsed'].includes(value.code)){active=false;unavailable();void close().catch(()=>{});}
|
|
35
38
|
};
|
|
36
39
|
const runtime=new AdapterRuntime({client,inbox,verifySession:alive,isAlive:alive,onDiagnostic:diagnostic});
|
|
37
|
-
const close=()=>stopping??=(async()=>{active=false;clearInterval(monitor);try{await runtime.stop({closeSession:!isAlive()});}finally{inbox.close();}})();
|
|
40
|
+
const close=()=>stopping??=(async()=>{active=false;recoveryAbort.abort();clearInterval(monitor);try{await toolScope.close();await recoveryPromise?.catch(()=>{});await runtime.stop({closeSession:!isAlive()});}finally{inbox.close();}})();
|
|
38
41
|
const abort=()=>{void close().catch(()=>{});};signal?.addEventListener('abort',abort,{once:true});
|
|
39
42
|
try {
|
|
40
43
|
if(!alive())throw Error('native_host_unavailable');
|
|
41
44
|
monitor=setInterval(()=>{if(!alive())void close().catch(()=>{});},1000);monitor.unref?.();
|
|
42
45
|
await runtime.start(config.metadata);
|
|
43
46
|
if(!alive())throw Error('native_host_unavailable');
|
|
44
|
-
|
|
45
|
-
|
|
47
|
+
recoveryPromise=client.recoverOutgoing({limit:10,signal:AbortSignal.any([recoveryAbort.signal,...signal?[signal]:[],AbortSignal.timeout(5000)])});
|
|
48
|
+
await recoveryPromise.catch(()=>onDiagnostic({code:'outgoing_recovery_incomplete'}));
|
|
49
|
+
if(!alive())throw Error('native_host_unavailable');
|
|
50
|
+
const facade={...createTools({client,inbox}),...createCooperativeInbox({inbox,isActive:alive}),
|
|
51
|
+
broker_identity:async()=>({...identity,conversation_id:conversationId,endpoint_scope:'experimental_native_conversation',native_session_binding:true,mode:'automatic_required',delivery:'native_waiter',readiness:'native_wakeup_acceptance_unverified',automatic_delivery_certified:false})};
|
|
52
|
+
const tools=Object.fromEntries(Object.entries(facade).map(([name,fn])=>[name,(args,context={})=>toolScope.run(async operationSignal=>{if(!alive())throw Error('native_host_unavailable');return fn(args,{...context,signal:signal?AbortSignal.any([signal,operationSignal]):operationSignal});},context.signal)]));
|
|
46
53
|
return {identity,inbox,tools,async close(){signal?.removeEventListener('abort',abort);await close();}};
|
|
47
54
|
} catch(error){signal?.removeEventListener('abort',abort);await close();throw error;}
|
|
48
55
|
}
|
package/src/pairing.mjs
CHANGED
|
@@ -16,5 +16,5 @@ export async function pairConnector(options, dependencies = {}) {
|
|
|
16
16
|
readConnectorConfig(result.connectionFile, {});
|
|
17
17
|
const receipt = readEnrollmentReceipt({ connectionFile: result.connectionFile, clientType: 'antigravity-cli' });
|
|
18
18
|
if (receipt.credentialId !== result.credentialId || receipt.expiresAt !== result.expiresAt) throw new Error('invalid_enrollment_receipt');
|
|
19
|
-
return { ...result, connected: false, next_step: '
|
|
19
|
+
return { ...result, connected: false, next_step: 'Pairing saved authorization only. Production requires the native automatic-delivery plugin; legacy stdio is diagnostic/test-only. No native conversation was connected.' };
|
|
20
20
|
}
|
package/src/reference-pin.mjs
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { readFileSync, lstatSync } from 'node:fs';
|
|
2
2
|
import { join, isAbsolute } from 'node:path';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
-
export const REFERENCE_COMMIT = '
|
|
4
|
+
export const REFERENCE_COMMIT = '2de9cc3758b9fc593f5afca4fc501fd013d416ec';
|
|
5
5
|
const HASHES = Object.freeze({
|
|
6
|
-
"reference/inbox.mjs": "
|
|
7
|
-
"reference/client.mjs": "
|
|
6
|
+
"reference/inbox.mjs": "fbf0fa6096996cb9bc3f928e55f3eca496420880a03460e993dda3417b32ad88",
|
|
7
|
+
"reference/client.mjs": "c4c313e937d1003f28928a672072b926022aa0e9fd65825bc5db1078eb90afa9",
|
|
8
8
|
"reference/runtime.mjs": "8733076b73a42ab8f12fe20ba9908acdd754d89734ffd335e84b49aee9d737ca",
|
|
9
|
-
"reference/tools.mjs": "
|
|
10
|
-
"reference/mcp-stdio.mjs": "
|
|
9
|
+
"reference/tools.mjs": "5d3b16212a048912395d74b95a8b590d278b8b0915bb0bd7a0c3d80daf353839",
|
|
10
|
+
"reference/mcp-stdio.mjs": "f34b0cdcda9c2d607815e4ef99f1ba80fbd8936ef8c2893a858b6d408bc0ff09",
|
|
11
11
|
"reference/errors.mjs": "e33231d94f514b5a1b59fdd4184f5881dd3712bde725a1cf53352c26293452ac",
|
|
12
|
-
"src/protocol.mjs": "
|
|
12
|
+
"src/protocol.mjs": "2d88829971643d47430d6696d0a6ac61589ea4f9001c860c5c196ecc5ac21a6f"
|
|
13
13
|
});
|
|
14
14
|
/** Verify reviewed source bytes before importing any trusted local module. */
|
|
15
15
|
export function verifyReference(directory) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** An owner's admitted tool calls must settle before its private ledger closes. */
|
|
2
|
+
export function createToolScope({ closedError = () => new Error('tool_scope_closed'), abortedError = () => new Error('tool_call_aborted') } = {}) {
|
|
3
|
+
const controller = new AbortController(), pending = new Set();
|
|
4
|
+
let closed = false, closing;
|
|
5
|
+
return Object.freeze({
|
|
6
|
+
run(task, callerSignal) {
|
|
7
|
+
if (closed) return Promise.reject(closedError());
|
|
8
|
+
const signal = callerSignal instanceof AbortSignal
|
|
9
|
+
? AbortSignal.any([controller.signal, callerSignal]) : controller.signal;
|
|
10
|
+
const operation = Promise.resolve().then(() => {
|
|
11
|
+
if (closed) throw closedError();
|
|
12
|
+
if (signal.aborted) throw abortedError();
|
|
13
|
+
return task(signal);
|
|
14
|
+
});
|
|
15
|
+
pending.add(operation);
|
|
16
|
+
void operation.then(() => pending.delete(operation), () => pending.delete(operation));
|
|
17
|
+
return operation;
|
|
18
|
+
},
|
|
19
|
+
close() {
|
|
20
|
+
if (closing) return closing;
|
|
21
|
+
closed = true; controller.abort();
|
|
22
|
+
closing = Promise.allSettled([...pending]).then(() => {});
|
|
23
|
+
return closing;
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|
package/vendor/psst/README.md
CHANGED
|
@@ -1,18 +1,12 @@
|
|
|
1
|
-
# Pinned Psst runtime
|
|
1
|
+
# Pinned Psst runtime candidate
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
`
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
source digests during tests. No runtime download, postinstall script, or sibling
|
|
8
|
-
broker checkout is needed by the packaged MCP connector.
|
|
3
|
+
Seven runtime/protocol files are copied byte-for-byte from Psst candidate commit
|
|
4
|
+
`2de9cc3758b9fc593f5afca4fc501fd013d416ec` and verified by `src/reference-pin.mjs`. This is a local, unreleased
|
|
5
|
+
candidate, not evidence of registry publication or deployed broker support.
|
|
6
|
+
Source repository: https://github.com/matuto-ai/psst . MIT license retained.
|
|
9
7
|
|
|
10
|
-
The
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
and SHA256 are in `approval-source.json` and checked by `test/approval-pin.test.mjs`.
|
|
16
|
-
It depends on the broker's approval API (PR22), not on v0.1.0; an unreleased source
|
|
17
|
-
pin does not imply that a deployed broker has that capability. Legacy transport
|
|
18
|
-
files and the separate enrollment helper retain their original reviewed pins.
|
|
8
|
+
The new outgoing history/content/resend/cancel APIs require the matching broker
|
|
9
|
+
candidate. Existing protocol-v1 send/receive remains compatible. Older brokers
|
|
10
|
+
return an explicit unsupported HTTP error for new routes; no local success is fabricated.
|
|
11
|
+
Approval/enrollment helpers and test-only broker fixtures retain their separate
|
|
12
|
+
original pins. Connector display and read acknowledgment changes live in `src/`.
|
|
@@ -8,7 +8,7 @@ import { ApiError, canonical, validateMetadata, validateRegistration, validateMe
|
|
|
8
8
|
import { LocalError } from './errors.mjs';
|
|
9
9
|
|
|
10
10
|
const ALLOWED_ERROR_CODES = new Set(['invalid_request', 'unauthenticated', 'forbidden', 'recipient_not_found',
|
|
11
|
-
'message_not_found', 'identity_conflict', 'stale_lease_epoch', 'idempotency_conflict', 'metadata_conflict',
|
|
11
|
+
'message_not_found', 'history_changed', 'content_unavailable', 'retry_window_elapsed', 'identity_conflict', 'stale_lease_epoch', 'idempotency_conflict', 'metadata_conflict',
|
|
12
12
|
'claim_conflict', 'session_closed', 'message_expired', 'payload_too_large',
|
|
13
13
|
'incompatible_receiver_transport', 'receiving_disabled', 'rate_limited', 'mailbox_full',
|
|
14
14
|
'storage_unavailable', 'not_ready']);
|
|
@@ -95,12 +95,13 @@ export class BrokerClient {
|
|
|
95
95
|
return token;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
async request(method, path, { body, key, session = true, signal, idempotent = false, deadline = Infinity } = {}) {
|
|
98
|
+
async request(method, path, { body, key, session = true, signal, idempotent = false, deadline = Infinity, onError = () => {} } = {}) {
|
|
99
99
|
if (!/^\/v1\/[A-Za-z0-9_/?=&.%+~-]+$/.test(path)) throw new LocalError('invalid_configuration');
|
|
100
100
|
for (let attempt = 0; ; attempt++) {
|
|
101
101
|
if (this.now() >= deadline) throw new LocalError('retry_window_elapsed', { effect: 'unknown' });
|
|
102
102
|
try { return await this.requestOnce(method, path, { body, key, session, signal, timeoutMs: Math.min(this.requestTimeoutMs, deadline - this.now()) }); }
|
|
103
103
|
catch (error) {
|
|
104
|
+
onError(error);
|
|
104
105
|
if (signal?.aborted) throw new LocalError('request_aborted', { effect: 'unknown' });
|
|
105
106
|
const retryable = error instanceof BrokerError ? error.retryable
|
|
106
107
|
: error instanceof LocalError && error.code === 'request_failed';
|
|
@@ -155,6 +156,7 @@ export class BrokerClient {
|
|
|
155
156
|
const retryAfter = /^\d+$/.test(String(response.headers['retry-after'] ?? '')) ? Math.min(3600, Number(response.headers['retry-after'])) : 0;
|
|
156
157
|
finish(new BrokerError(status, value, retryAfter)); return;
|
|
157
158
|
}
|
|
159
|
+
if(['true','false'].includes(response.headers['idempotency-replayed']))value.acceptance_replayed=response.headers['idempotency-replayed']==='true';
|
|
158
160
|
const timestamp = value?.server_time;
|
|
159
161
|
// Registration/send/completion retries may return their original
|
|
160
162
|
// stored result. Historical server_time is not a current clock sample.
|
|
@@ -269,23 +271,96 @@ export class BrokerClient {
|
|
|
269
271
|
return metadata;
|
|
270
272
|
}
|
|
271
273
|
|
|
274
|
+
operationOptions(options = {}) {
|
|
275
|
+
const {operationStartedAt,...rest}=options,seconds=positiveSeconds(this.policy.send_retry_seconds,86400);
|
|
276
|
+
if(operationStartedAt!==undefined&&(!Number.isSafeInteger(operationStartedAt)||operationStartedAt>this.now()+300000||this.now()-operationStartedAt>=seconds*1000))throw new LocalError('retry_window_elapsed',{effect:'unknown'});
|
|
277
|
+
return {retryWindowSeconds:seconds,issuedAt:Math.trunc((operationStartedAt??this.now())+this.clockOffset),...(operationStartedAt===undefined?{}:{retryUntil:operationStartedAt+seconds*1000}),...rest};
|
|
278
|
+
}
|
|
279
|
+
|
|
272
280
|
prepareSend(request, options = {}) {
|
|
273
|
-
return this.inbox.prepareSend(request,
|
|
281
|
+
return this.inbox.prepareSend(request,this.operationOptions(options));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async sendPrepared(key, options = {}) { return this.outgoingRequest(key,'send',options); }
|
|
285
|
+
|
|
286
|
+
prepareResend(messageId, input = {}, options = {}) {
|
|
287
|
+
return this.inbox.prepareResend(messageId,input,this.operationOptions(options));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async resendPrepared(key, options = {}) { return this.outgoingRequest(key,'resend',options); }
|
|
291
|
+
|
|
292
|
+
async outgoingRequest(key, kind, options) {
|
|
293
|
+
// Shared by clients using this exact journal: concurrent invocations cannot
|
|
294
|
+
// race a definite rejection against another still-running network attempt.
|
|
295
|
+
const inflight=this.inbox.outgoingRequests??=new Map();
|
|
296
|
+
if(inflight.has(key))return inflight.get(key);
|
|
297
|
+
const work=this.transmitOutgoing(key,kind,options);inflight.set(key,work);
|
|
298
|
+
try{return await work;}finally{inflight.delete(key);}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async transmitOutgoing(key, kind, { signal } = {}) {
|
|
302
|
+
const operation=this.inbox.outgoing(key);
|
|
303
|
+
if(!operation||operation.kind!==kind)throw new LocalError('invalid_configuration');
|
|
304
|
+
if(operation.result)return {...operation.result,acceptance_replayed:true,send_key:key};
|
|
305
|
+
if(operation.failure)throw new BrokerError(operation.failure.status,operation.failure.value);
|
|
306
|
+
let priorUnknown=!!operation.uncertain;
|
|
307
|
+
try{this.inbox.markOutgoingAttempt(key);}catch(error){
|
|
308
|
+
if(priorUnknown)throw new LocalError('storage_unavailable',{effect:'unknown'});
|
|
309
|
+
throw error; // a wholly new operation has not reached the network
|
|
310
|
+
} // crash before/after HTTP stays uncertain
|
|
311
|
+
const body=kind==='send'?operation.request:Object.fromEntries(Object.entries(operation.request).filter(([k])=>k!=='message_id'));
|
|
312
|
+
const path=kind==='send'?'/v1/messages':`/v1/messages/${validateUuid(operation.request.message_id)}/resend`;
|
|
313
|
+
let result;
|
|
314
|
+
try{
|
|
315
|
+
result=await this.request('POST',path,{body,key,idempotent:true,deadline:operation.retry_until,signal,onError:error=>{if(error.effect!=='not_applied')priorUnknown=true;}});
|
|
316
|
+
}catch(error){
|
|
317
|
+
// A rejection of a later retry is not evidence that an earlier unknown
|
|
318
|
+
// attempt was rejected. Only a first, wholly known-not-applied operation
|
|
319
|
+
// may become a durable terminal failure; other intents remain independent.
|
|
320
|
+
if(!priorUnknown&&error instanceof BrokerError&&error.effect==='not_applied'&&!error.retryable)this.inbox.recordSendFailure(key,{status:error.status,value:error.toJSON()});
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
try{validateUuid(result?.message_id);}catch{throw new LocalError('invalid_response',{effect:'unknown'});}
|
|
324
|
+
try{
|
|
325
|
+
this.inbox.recordSendResult(key,result);
|
|
326
|
+
if(kind==='send'&&operation.request.metadata_patch)this.inbox.setState('metadata',{...this.inbox.getState('metadata'),...operation.request.metadata_patch});
|
|
327
|
+
}catch{
|
|
328
|
+
// The broker already accepted this operation. A failed local result or
|
|
329
|
+
// metadata commit describes only the journal, never remote non-delivery.
|
|
330
|
+
throw new LocalError('storage_unavailable',{effect:'unknown'});
|
|
331
|
+
}
|
|
332
|
+
return {...result,send_key:key};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async cancelMessage(messageId, { signal } = {}) {
|
|
336
|
+
return this.request('POST',`/v1/messages/${validateUuid(messageId)}/cancel`,{body:{},idempotent:true,signal});
|
|
274
337
|
}
|
|
275
338
|
|
|
276
|
-
async
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
// Sender revision is not guessed; reconcile from owned state before a future CAS mutation.
|
|
339
|
+
async recoverOutgoing({ limit = 10, signal } = {}) {
|
|
340
|
+
if(!Number.isSafeInteger(limit)||limit<1||limit>100)throw new LocalError('invalid_configuration');
|
|
341
|
+
const records=[];
|
|
342
|
+
const {operations,expiredCount}=this.inbox.outgoingRecovery(limit,this.now());
|
|
343
|
+
for(const operation of operations){
|
|
344
|
+
try{
|
|
345
|
+
const pending=this.inbox.outgoing(operation.key);
|
|
346
|
+
const value=await (pending.kind==='resend'?this.resendPrepared(operation.key,{signal}):this.sendPrepared(operation.key,{signal}));
|
|
347
|
+
const {send_key,...result}=value;records.push({operation:pending.kind,result});
|
|
348
|
+
}catch(error){records.push({error:error instanceof LocalError||error instanceof BrokerError?error.toJSON().error:new LocalError('request_failed',{effect:'unknown'}).toJSON().error});}
|
|
287
349
|
}
|
|
288
|
-
|
|
350
|
+
if(expiredCount)records.push({...new LocalError('retry_window_elapsed',{effect:'unknown'}).toJSON(),count:expiredCount});
|
|
351
|
+
return records;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async sentHistory(filters = {}, { signal } = {}) {
|
|
355
|
+
const allowed=['recipient_id','status','accepted_after','accepted_before','limit','cursor'];
|
|
356
|
+
if(!filters||typeof filters!=='object'||Array.isArray(filters)||Object.keys(filters).some(key=>!allowed.includes(key))) throw new LocalError('invalid_configuration');
|
|
357
|
+
const query=new URLSearchParams();
|
|
358
|
+
for(const [key,value] of Object.entries(filters)) query.set(key,String(value));
|
|
359
|
+
return this.request('GET',`/v1/messages${query.size?`?${query}`:''}`,{idempotent:true,signal});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async messageContent(messageId, { signal } = {}) {
|
|
363
|
+
return this.request('GET',`/v1/messages/${validateUuid(messageId)}/content`,{idempotent:true,signal});
|
|
289
364
|
}
|
|
290
365
|
|
|
291
366
|
async messageStatus(messageId, { signal } = {}) {
|
|
@@ -2,7 +2,7 @@ import { DatabaseSync } from 'node:sqlite';
|
|
|
2
2
|
import { mkdirSync, lstatSync, statfsSync, readdirSync, chmodSync, realpathSync } from 'node:fs';
|
|
3
3
|
import { resolve, join } from 'node:path';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import { canonical, digest, validateEnvelope, validateUuid, validateSend, validateIdempotencyKey, TYPES } from '../src/protocol.mjs';
|
|
5
|
+
import { canonical, digest, validateEnvelope, validateUuid, validateSend, validateIdempotencyKey, validateResend, TYPES } from '../src/protocol.mjs';
|
|
6
6
|
import { LocalError } from './errors.mjs';
|
|
7
7
|
|
|
8
8
|
export function newSessionIdentity(clientType) {
|
|
@@ -68,6 +68,12 @@ export class Inbox {
|
|
|
68
68
|
key TEXT PRIMARY KEY, digest TEXT NOT NULL, request TEXT NOT NULL,
|
|
69
69
|
created_at INTEGER NOT NULL, retry_until INTEGER NOT NULL, result TEXT);
|
|
70
70
|
PRAGMA user_version=1;`);
|
|
71
|
+
if(!this.db.prepare('PRAGMA table_info(outgoing)').all().some(c=>c.name==='kind'))this.db.exec("ALTER TABLE outgoing ADD COLUMN kind TEXT NOT NULL DEFAULT 'send'");
|
|
72
|
+
for(const [name,type] of [['failure','TEXT'],['uncertain','INTEGER NOT NULL DEFAULT 0'],['operation_id','TEXT']]){
|
|
73
|
+
if(!this.db.prepare('PRAGMA table_info(outgoing)').all().some(c=>c.name===name))this.db.exec(`ALTER TABLE outgoing ADD COLUMN ${name} ${type}`);
|
|
74
|
+
}
|
|
75
|
+
this.db.exec('CREATE UNIQUE INDEX IF NOT EXISTS outgoing_operation ON outgoing(operation_id) WHERE operation_id IS NOT NULL');
|
|
76
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS outgoing_recovery ON outgoing(retry_until,created_at,key) WHERE result IS NULL AND failure IS NULL');
|
|
71
77
|
const stored = this.getState('identity');
|
|
72
78
|
if (stored && canonical(stored) !== canonical(this.identity)) throw new LocalError('session_mismatch');
|
|
73
79
|
if (!stored) this.setState('identity', this.identity);
|
|
@@ -234,33 +240,62 @@ export class Inbox {
|
|
|
234
240
|
});
|
|
235
241
|
}
|
|
236
242
|
|
|
237
|
-
prepareSend(rawRequest,
|
|
238
|
-
|
|
239
|
-
|
|
243
|
+
prepareSend(rawRequest, options = {}) {
|
|
244
|
+
return this.prepareOperation('send',validateSend(rawRequest),options);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
prepareResend(messageId, input = {}, options = {}) {
|
|
248
|
+
const validated=validateResend(input);
|
|
249
|
+
return this.prepareOperation('resend',{message_id:validateUuid(messageId),...(input.ttl_seconds===undefined?{}:validated)},options);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
prepareOperation(kind, request, { key, operationId, issuedAt=this.now(), retryUntil, retryWindowSeconds = 86400 } = {}) {
|
|
253
|
+
if(operationId!==undefined&&(typeof operationId!=='string'||!/^[a-f0-9]{64}$/.test(operationId)))throw new LocalError('invalid_configuration');
|
|
254
|
+
const mapped=operationId?this.outgoingForOperation(operationId):null;
|
|
255
|
+
key = validateIdempotencyKey(key??mapped?.key??`op_${issuedAt}_${randomUUID().replaceAll('-','')}`);
|
|
240
256
|
const encoded = canonical(request);
|
|
241
257
|
const hash = digest(request);
|
|
242
258
|
const existing = this.outgoing(key);
|
|
243
259
|
if (existing) {
|
|
244
|
-
if (existing.digest !== hash) throw new LocalError('send_conflict');
|
|
260
|
+
if (existing.digest !== hash || existing.kind !== kind) throw new LocalError('send_conflict');
|
|
245
261
|
return key;
|
|
246
262
|
}
|
|
263
|
+
const started=/^op_([0-9]{13})_/.exec(key);
|
|
264
|
+
if(started&&issuedAt-Number(started[1])>=retryWindowSeconds*1000)throw new LocalError('retry_window_elapsed',{effect:'unknown'});
|
|
265
|
+
if(retryUntil!==undefined&&(!Number.isSafeInteger(retryUntil)||retryUntil<=this.now()))throw new LocalError('retry_window_elapsed',{effect:'unknown'});
|
|
247
266
|
if (!Number.isSafeInteger(retryWindowSeconds) || retryWindowSeconds < 1) throw new LocalError('invalid_configuration');
|
|
248
267
|
this.transaction(() => {
|
|
268
|
+
this.db.prepare('DELETE FROM outgoing WHERE key IN (SELECT key FROM outgoing WHERE (result IS NOT NULL OR failure IS NOT NULL) AND retry_until<? LIMIT 100)').run(this.now());
|
|
249
269
|
if (this.db.prepare('SELECT COUNT(*) AS n FROM outgoing').get().n >= this.limits.maxOutgoingCount) throw new LocalError('inbox_full');
|
|
250
|
-
this.db.prepare('INSERT INTO outgoing(key,digest,request,created_at,retry_until) VALUES(
|
|
270
|
+
this.db.prepare('INSERT INTO outgoing(key,digest,request,created_at,retry_until,kind,operation_id) VALUES(?,?,?,?,?,?,?)').run(key, hash, encoded, this.now(), Math.min(retryUntil??Infinity,this.now()+retryWindowSeconds*1000),kind,operationId??null);
|
|
251
271
|
}, { growth: Buffer.byteLength(encoded) * 3 + 8192 });
|
|
252
272
|
return key;
|
|
253
273
|
}
|
|
254
274
|
|
|
275
|
+
outgoingForOperation(operationId) {
|
|
276
|
+
const row=this.db.prepare('SELECT key FROM outgoing WHERE operation_id=?').get(operationId);
|
|
277
|
+
return row?this.outgoing(row.key):null;
|
|
278
|
+
}
|
|
279
|
+
|
|
255
280
|
outgoing(key) {
|
|
256
281
|
this.assertUsable();
|
|
257
282
|
const row = this.db.prepare('SELECT * FROM outgoing WHERE key=?').get(key);
|
|
258
|
-
return row ? { ...row, request: JSON.parse(row.request), result: row.result ? JSON.parse(row.result) : null } : null;
|
|
283
|
+
return row ? { ...row, request: JSON.parse(row.request), result: row.result ? JSON.parse(row.result) : null, failure: row.failure ? JSON.parse(row.failure) : null } : null;
|
|
259
284
|
}
|
|
260
285
|
|
|
261
286
|
unresolvedSends() {
|
|
262
287
|
this.assertUsable();
|
|
263
|
-
return this.db.prepare('SELECT key,created_at,retry_until FROM outgoing WHERE result IS NULL ORDER BY created_at LIMIT 100').all().map(row => ({ ...row }));
|
|
288
|
+
return this.db.prepare('SELECT key,created_at,retry_until FROM outgoing WHERE result IS NULL AND failure IS NULL ORDER BY created_at LIMIT 100').all().map(row => ({ ...row }));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
outgoingRecovery(limit = 10, now = this.now()) {
|
|
292
|
+
this.assertUsable();
|
|
293
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(now)) throw new LocalError('invalid_configuration');
|
|
294
|
+
// Filter before LIMIT: retained, expired unknowns must never starve live retries.
|
|
295
|
+
// Earliest deadline first uses the partial index without sorting/scanning old rows.
|
|
296
|
+
const operations = this.db.prepare('SELECT key,created_at,retry_until FROM outgoing WHERE result IS NULL AND failure IS NULL AND retry_until>? ORDER BY retry_until,created_at,key LIMIT ?').all(now, limit).map(row => ({ ...row }));
|
|
297
|
+
const expiredCount = this.db.prepare('SELECT count(*) AS n FROM outgoing WHERE result IS NULL AND failure IS NULL AND retry_until<=?').get(now).n;
|
|
298
|
+
return { operations, expiredCount };
|
|
264
299
|
}
|
|
265
300
|
|
|
266
301
|
recordSendResult(key, result) {
|
|
@@ -268,15 +303,25 @@ export class Inbox {
|
|
|
268
303
|
if (Buffer.byteLength(encoded) > 16384) throw new LocalError('invalid_response', { effect: 'unknown' });
|
|
269
304
|
this.transaction(() => {
|
|
270
305
|
if (!this.outgoing(key)) throw new LocalError('invalid_configuration');
|
|
271
|
-
this.db.prepare('UPDATE outgoing SET result
|
|
306
|
+
this.db.prepare('UPDATE outgoing SET result=?,failure=NULL,uncertain=0 WHERE key=?').run(encoded, key);
|
|
272
307
|
}, { growth: Buffer.byteLength(encoded) * 2 + 8192 });
|
|
273
308
|
}
|
|
274
309
|
|
|
310
|
+
markOutgoingAttempt(key) {
|
|
311
|
+
this.transaction(()=>this.db.prepare('UPDATE outgoing SET uncertain=1 WHERE key=?').run(key));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
recordSendFailure(key, failure) {
|
|
315
|
+
const encoded=canonical(failure);
|
|
316
|
+
if(Buffer.byteLength(encoded)>16384)throw new LocalError('invalid_response',{effect:'unknown'});
|
|
317
|
+
this.transaction(()=>this.db.prepare('UPDATE outgoing SET failure=?,uncertain=0 WHERE key=? AND result IS NULL').run(encoded,key),{growth:Buffer.byteLength(encoded)*2+8192});
|
|
318
|
+
}
|
|
319
|
+
|
|
275
320
|
collect(limit = 100) {
|
|
276
321
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) throw new LocalError('invalid_configuration');
|
|
277
322
|
return this.transaction(() => {
|
|
278
323
|
const removedInbox = this.db.prepare("DELETE FROM inbox WHERE message_id IN (SELECT message_id FROM inbox WHERE state='consumed' AND retain_until<? ORDER BY retain_until LIMIT ?)").run(this.now(), limit).changes;
|
|
279
|
-
const removedSends = this.db.prepare('DELETE FROM outgoing WHERE key IN (SELECT key FROM outgoing WHERE result IS NOT NULL AND retry_until<? ORDER BY retry_until LIMIT ?)').run(this.now(), limit).changes;
|
|
324
|
+
const removedSends = this.db.prepare('DELETE FROM outgoing WHERE key IN (SELECT key FROM outgoing WHERE (result IS NOT NULL OR failure IS NOT NULL) AND retry_until<? ORDER BY retry_until LIMIT ?)').run(this.now(), limit).changes;
|
|
280
325
|
return { removed_inbox: removedInbox, removed_sends: removedSends };
|
|
281
326
|
});
|
|
282
327
|
}
|
|
@@ -13,8 +13,12 @@ const metadata = object({ name: text(128), model, project: { type: ['string', 'n
|
|
|
13
13
|
|
|
14
14
|
export const TOOL_DEFINITIONS = Object.freeze([
|
|
15
15
|
{ name: 'broker_list_clients', description: 'List authorized exact session addresses. Names and models are descriptive, not routing authority.', inputSchema: object({ connected: { type: 'boolean' }, client_type: { enum: ['pi', 'antigravity-cli', 'claude-code', 'opencode'] }, name: text(128), project: text(256), cursor: text(256), limit: { type: 'integer', minimum: 1, maximum: 100 } }) },
|
|
16
|
-
{ name: 'broker_send', description: 'Send opaque untrusted AGENT text to one exact client_id. Acceptance means broker storage only;
|
|
16
|
+
{ name: 'broker_send', description: 'Send opaque untrusted AGENT text to one exact client_id. Acceptance means broker storage only; internal operation identity handles technical retries.', inputSchema: object({ recipient_id: uuid, text: text(65536), ttl_seconds: { type: 'integer', minimum: 60, maximum: 604800 }, correlation_id: text(128), reply_to_message_id: uuid }, ['recipient_id', 'text']) },
|
|
17
17
|
{ name: 'broker_update_metadata', description: 'CAS update of descriptive name/model/project only. Metadata does not grant access or change identity.', inputSchema: object({ metadata_patch: metadata, expected_metadata_revision: { type: 'integer', minimum: 1 } }, ['metadata_patch', 'expected_metadata_revision']) },
|
|
18
|
+
{ name: 'broker_sent_history', description: 'Read only this exact session’s outgoing metadata, newest first. Default 10, maximum 100, 32768-byte cap. No content. Preserve filters on continuation; restart on history_changed.', inputSchema: object({ recipient_id: uuid, status: { enum: ['queued','claimed','delivered','expired','failed','cancelled'] }, accepted_after: text(24), accepted_before: text(24), cursor: text(1024), limit: { type: 'integer', minimum: 1, maximum: 100 } }) },
|
|
19
|
+
{ name: 'broker_message_content', description: 'Explicitly retrieve the original content and current transport receipt of one owned outgoing message.', inputSchema: object({ message_id: uuid }, ['message_id']) },
|
|
20
|
+
{ name: 'broker_resend', description: 'Explicit new delivery of an owned retained message, with a new message ID. May duplicate a prior delivery; never an automatic technical retry.', inputSchema: object({ message_id: uuid, ttl_seconds: { type: 'integer', minimum: 60, maximum: 604800 } }, ['message_id']) },
|
|
21
|
+
{ name: 'broker_cancel', description: 'Stop further pending delivery of one owned message. Cannot undo inbox content; inspect uncertainty and cancel_result.', inputSchema: object({ message_id: uuid }, ['message_id']) },
|
|
18
22
|
{ name: 'broker_message_status', description: 'Read broker delivery state, never model progress or task completion.', inputSchema: object({ message_id: uuid }, ['message_id']) },
|
|
19
23
|
{ name: 'broker_receive', description: 'Cooperatively peek at the oldest durable AGENT inbox item during this tool call. No unsolicited wakeup or native-consumption receipt; the item remains pending.', inputSchema: object() },
|
|
20
24
|
]);
|
|
@@ -1,15 +1,25 @@
|
|
|
1
|
+
import { digest } from '../src/protocol.mjs';
|
|
1
2
|
import { LocalError, safeError } from './errors.mjs';
|
|
2
3
|
import { provenance } from './runtime.mjs';
|
|
3
4
|
|
|
4
5
|
/** Native host tool facade, not an MCP transport or a generic TUI injection API. */
|
|
5
6
|
export function createTools({ client, inbox = client?.inbox, observeMetadata = null, cooperativeBoundary = null }) {
|
|
6
7
|
if (!client || !inbox) throw new LocalError('invalid_configuration');
|
|
8
|
+
const internalOperation=(kind,context)=>{
|
|
9
|
+
if(context?.operationId!==undefined){
|
|
10
|
+
if(typeof context.operationId!=='string'||!context.operationId||context.operationId.length>1024)throw new LocalError('invalid_configuration');
|
|
11
|
+
const operationId=digest({kind,operation_id:context.operationId}),existing=inbox.outgoingForOperation(operationId);
|
|
12
|
+
return {operationId,...(existing?{key:existing.key}:{}),...(context.operationStartedAt===undefined?{}:{operationStartedAt:context.operationStartedAt})};
|
|
13
|
+
}
|
|
14
|
+
return {};
|
|
15
|
+
};
|
|
16
|
+
const publicResult=value=>{const {send_key,...result}=value;return result;};
|
|
7
17
|
return {
|
|
8
|
-
async broker_list_clients(arguments_ = {}) { return client.listClients(arguments_); },
|
|
9
|
-
async broker_send(arguments_) {
|
|
18
|
+
async broker_list_clients(arguments_ = {}, context = {}) { return client.listClients(arguments_,{signal:context.signal}); },
|
|
19
|
+
async broker_send(arguments_, context = {}) {
|
|
10
20
|
if (!arguments_ || typeof arguments_ !== 'object' || Array.isArray(arguments_)
|
|
11
|
-
|| Object.keys(arguments_).some(key => !['recipient_id', 'text', 'ttl_seconds', 'correlation_id', 'reply_to_message_id'
|
|
12
|
-
let key
|
|
21
|
+
|| Object.keys(arguments_).some(key => !['recipient_id', 'text', 'ttl_seconds', 'correlation_id', 'reply_to_message_id'].includes(key))) throw new LocalError('invalid_configuration');
|
|
22
|
+
const operation=internalOperation('send',context);let key=operation.key;
|
|
13
23
|
try {
|
|
14
24
|
const request = { recipient_id: arguments_.recipient_id, content: { type: 'text/plain', text: arguments_.text } };
|
|
15
25
|
for (const field of ['ttl_seconds', 'correlation_id', 'reply_to_message_id']) if (Object.hasOwn(arguments_, field)) request[field] = arguments_[field];
|
|
@@ -21,21 +31,35 @@ export function createTools({ client, inbox = client?.inbox, observeMetadata = n
|
|
|
21
31
|
} else if (!original && observeMetadata) {
|
|
22
32
|
const observed = await observeMetadata();
|
|
23
33
|
if (observed) {
|
|
24
|
-
await client.refreshOwnedMetadata();
|
|
34
|
+
await client.refreshOwnedMetadata({signal:context.signal});
|
|
25
35
|
request.metadata_patch = observed; request.expected_metadata_revision = client.attachment.metadata_revision;
|
|
26
36
|
}
|
|
27
37
|
}
|
|
28
|
-
key = client.prepareSend(request,
|
|
29
|
-
return await client.sendPrepared(key);
|
|
30
|
-
} catch (error) { return
|
|
38
|
+
key = client.prepareSend(request,operation);
|
|
39
|
+
return publicResult(await client.sendPrepared(key,{signal:context.signal}));
|
|
40
|
+
} catch (error) { return safeError(error); }
|
|
31
41
|
},
|
|
32
|
-
async broker_update_metadata(arguments_) {
|
|
42
|
+
async broker_update_metadata(arguments_, context = {}) {
|
|
33
43
|
if (!arguments_ || Object.keys(arguments_).some(key => !['metadata_patch', 'expected_metadata_revision'].includes(key))) throw new LocalError('invalid_configuration');
|
|
34
|
-
return client.updateMetadata(arguments_.metadata_patch, { expectedRevision: arguments_.expected_metadata_revision });
|
|
44
|
+
return client.updateMetadata(arguments_.metadata_patch, { expectedRevision: arguments_.expected_metadata_revision, signal:context.signal });
|
|
45
|
+
},
|
|
46
|
+
async broker_sent_history(arguments_ = {}, context = {}) { return client.sentHistory(arguments_,{signal:context.signal}); },
|
|
47
|
+
async broker_resend(arguments_, context = {}) {
|
|
48
|
+
if(!arguments_||Object.keys(arguments_).some(key=>!['message_id','ttl_seconds'].includes(key)))throw new LocalError('invalid_configuration');
|
|
49
|
+
const operation=internalOperation('resend',context),{message_id,...input}=arguments_;
|
|
50
|
+
return publicResult(await client.resendPrepared(client.prepareResend(message_id,input,operation),{signal:context.signal}));
|
|
51
|
+
},
|
|
52
|
+
async broker_cancel(arguments_, context = {}) {
|
|
53
|
+
if(!arguments_||Object.keys(arguments_).join(',')!=='message_id')throw new LocalError('invalid_configuration');
|
|
54
|
+
return client.cancelMessage(arguments_.message_id,{signal:context.signal});
|
|
55
|
+
},
|
|
56
|
+
async broker_message_content(arguments_, context = {}) {
|
|
57
|
+
if(!arguments_||Object.keys(arguments_).join(',')!=='message_id') throw new LocalError('invalid_configuration');
|
|
58
|
+
return client.messageContent(arguments_.message_id,{signal:context.signal});
|
|
35
59
|
},
|
|
36
|
-
async broker_message_status(arguments_) {
|
|
60
|
+
async broker_message_status(arguments_, context = {}) {
|
|
37
61
|
if (!arguments_ || Object.keys(arguments_).join(',') !== 'message_id') throw new LocalError('invalid_configuration');
|
|
38
|
-
return client.messageStatus(arguments_.message_id);
|
|
62
|
+
return client.messageStatus(arguments_.message_id,{signal:context.signal});
|
|
39
63
|
},
|
|
40
64
|
async broker_receive(arguments_ = {}) {
|
|
41
65
|
if (Object.keys(arguments_).length || typeof cooperativeBoundary !== 'function') throw new LocalError('unsafe_boundary');
|
|
@@ -52,8 +76,8 @@ export function createTools({ client, inbox = client?.inbox, observeMetadata = n
|
|
|
52
76
|
};
|
|
53
77
|
}
|
|
54
78
|
|
|
55
|
-
export async function invokeTool(tools, name, arguments_) {
|
|
79
|
+
export async function invokeTool(tools, name, arguments_, context = {}) {
|
|
56
80
|
if (!Object.hasOwn(tools, name) || typeof tools[name] !== 'function') return new LocalError('unknown_tool').toJSON();
|
|
57
|
-
try { return await tools[name](arguments_); }
|
|
81
|
+
try { return await tools[name](arguments_,context); }
|
|
58
82
|
catch (error) { return safeError(error); }
|
|
59
83
|
}
|
|
@@ -17,6 +17,9 @@ const ERROR_TEXT = Object.freeze({
|
|
|
17
17
|
forbidden: ['This operation is not authorized.', 'Check the enrolled adapter permissions.'],
|
|
18
18
|
recipient_not_found: ['The recipient is not available.', 'Select an exact authorized client ID from discovery.'],
|
|
19
19
|
message_not_found: ['The message is not available.', 'Use a message ID owned by this principal.'],
|
|
20
|
+
retry_window_elapsed: ['The operation recovery record has expired.', 'Reconcile the existing receipt; only an explicit new send or resend may create another delivery.'],
|
|
21
|
+
history_changed: ['History changed while paging.', 'Restart the listing without a cursor; no continuation was silently skipped.'],
|
|
22
|
+
content_unavailable: ['The original content is no longer retained.', 'Use the receipt; unavailable content cannot be reconstructed.'],
|
|
20
23
|
identity_conflict: ['The session identity conflicts with an existing registration.', 'Do not adopt another session; verify the local session identity.'],
|
|
21
24
|
stale_lease_epoch: ['Reconnect this session before retrying.', 'Read owned registration state and reconnect; do not blindly replay a mutation.'],
|
|
22
25
|
idempotency_conflict: ['This key was already used for a different request.', 'Reconcile the original request; do not change an uncertain request.'],
|
|
@@ -297,3 +300,19 @@ export function canonical(value) {
|
|
|
297
300
|
export function digest(value) {
|
|
298
301
|
return createHash('sha256').update(canonical(value), 'utf8').digest('hex');
|
|
299
302
|
}
|
|
303
|
+
|
|
304
|
+
export function validateResend(value, { limits = {} } = {}) {
|
|
305
|
+
object(value, ['ttl_seconds'], [], 'body');
|
|
306
|
+
const merged = { ...DEFAULTS, ...limits };
|
|
307
|
+
return { ttl_seconds: value.ttl_seconds === undefined ? merged.default_ttl_seconds : integer(value.ttl_seconds, Math.max(60, merged.min_ttl_seconds), Math.min(604800, merged.max_ttl_seconds), 'body.ttl_seconds') };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** New internal keys carry a timestamp; legacy keys retain their bounded v1 guarantee. */
|
|
311
|
+
export function validateOperationTime(key, now, retryWindowSeconds) {
|
|
312
|
+
if(!key.startsWith('op_'))return;
|
|
313
|
+
const match=/^op_([0-9]{13})_([A-Za-z0-9_-]{16,96})$/.exec(key);
|
|
314
|
+
if(!match)throw new ApiError(400,'invalid_request','headers.Idempotency-Key');
|
|
315
|
+
const started=Number(match[1]);
|
|
316
|
+
if(started>now+300000)throw new ApiError(400,'invalid_request','headers.Idempotency-Key');
|
|
317
|
+
if(now-started>=retryWindowSeconds*1000)throw new ApiError(410,'retry_window_elapsed','headers.Idempotency-Key');
|
|
318
|
+
}
|