@anynotate/protocol 0.1.0 → 0.2.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/PROTOCOL.md +57 -16
- package/README.md +2 -0
- package/dist/fixtures.d.ts +164 -0
- package/dist/fixtures.js +45 -0
- package/dist/index.d.ts +155 -0
- package/dist/index.js +23 -0
- package/package.json +7 -1
- package/src/fixtures.ts +70 -0
- package/src/index.ts +39 -0
package/PROTOCOL.md
CHANGED
|
@@ -1,30 +1,58 @@
|
|
|
1
|
-
# Anynotate protocol
|
|
1
|
+
# Anynotate protocol v1
|
|
2
2
|
|
|
3
|
-
The bridge is an HTTP server on `127.0.0.1`, port `47291` by default (`ANYNOTATE_PORT`). Clients send annotation **bundles**; the bridge stores each one as a folder and delivers it to an AI agent session.
|
|
3
|
+
The bridge is an HTTP server on `127.0.0.1`, port `47291` by default (`ANYNOTATE_PORT`). Clients send annotation **bundles**; the bridge stores each one as a folder and delivers it to an AI agent session. Constants and schemas live in `@anynotate/protocol`; request/response examples for the extension-facing endpoints live in `@anynotate/protocol/fixtures` (`conformance`).
|
|
4
|
+
|
|
5
|
+
## Handshake
|
|
6
|
+
|
|
7
|
+
`POST /health` (or `GET`) → `{ "ok": true, "bridgeVersion": "0.2.0", "protocol": { "version": 1, "min": 1 } }`.
|
|
8
|
+
|
|
9
|
+
A client supports a range of protocol versions (the extension: `min 1, max 1`) and calls `compatibility(clientRange, response.protocol)`:
|
|
10
|
+
|
|
11
|
+
- `ok` — the ranges overlap; proceed.
|
|
12
|
+
- `bridge-too-old` — ask the user to update the bridge; don't send.
|
|
13
|
+
- `extension-too-old` — ask the user to update the client.
|
|
14
|
+
|
|
15
|
+
A `/health` body without `protocol` comes from a pre-v1 bridge: treat it as `bridge-too-old`.
|
|
4
16
|
|
|
5
17
|
## Access
|
|
6
18
|
|
|
7
|
-
|
|
8
|
-
|
|
19
|
+
Checked in this order:
|
|
20
|
+
|
|
21
|
+
| Request | Result |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `Host` is not `127.0.0.1:<port>` or `localhost:<port>` | `403 {"error":"bad host"}` |
|
|
24
|
+
| `Origin` present and not on the allow-list | `403 {"error":"forbidden origin"}` (preflight too) |
|
|
25
|
+
| `OPTIONS` | `204`, with CORS headers for an allowed origin |
|
|
26
|
+
| `/health` | answered, no credentials needed |
|
|
27
|
+
| `X-Anynotate-Token` equal to `$ANYNOTATE_HOME/token` | allowed |
|
|
28
|
+
| anything else | `401 {"error":"bad token"}` |
|
|
29
|
+
|
|
30
|
+
An `Origin` on the allow-list earns CORS headers only; **the token is still required**, because any local process can send any `Origin`.
|
|
31
|
+
|
|
32
|
+
The allow-list is the valid `chrome-extension://` entries from `$ANYNOTATE_HOME/origins` and `ANYNOTATE_ALLOWED_ORIGINS`; `anynotate install` adds the Anynotate extension's id, and `anynotate origin add chrome-extension://<id>` adds a development build. Chrome attaches `Origin` to an extension's non-GET requests only, so **browser clients use the POST endpoints**; the GET variants remain for the CLI and scripts.
|
|
33
|
+
|
|
34
|
+
### How the extension gets the token
|
|
35
|
+
|
|
36
|
+
`anynotate install` registers a Chrome native messaging host named `dev.anynotate.host` (macOS: `~/Library/Application Support/Google/Chrome/NativeMessagingHosts/dev.anynotate.host.json`) whose `allowed_origins` are the allow-listed extension ids. Chrome launches it only for those extensions, running `~/.anynotate/native-host` as the user. Chrome starts the helper without the user's environment, so `ANYNOTATE_HOME` does not apply: the helper always reads `~/.anynotate/token` and `~/.anynotate/origins`. Messages use Chrome's native messaging framing (a 4-byte little-endian length, then UTF-8 JSON): the request `{ "type": "token" }` is answered with `{ "ok": true, "token": "<64 hex>" }`, or `{ "ok": false, "error": "<reason>" }` if the calling origin is not allowed or the request is unknown. The extension then sends the token as `X-Anynotate-Token`; nobody copies it by hand.
|
|
9
37
|
|
|
10
38
|
## Endpoints
|
|
11
39
|
|
|
12
40
|
| Method and path | Body | Response |
|
|
13
41
|
|---|---|---|
|
|
14
|
-
| `GET /health` | — | `
|
|
15
|
-
| `GET /sessions` | — | `Session[]`: live push sessions, herdr panes (`method: "herdr"`), then sessions seen by the prompt hook (`method: "next-prompt"`) |
|
|
16
|
-
| `POST /bundles` | `multipart/form-data`: field `bundle` = JSON `BundleInput`;
|
|
17
|
-
| `GET /bundles/<id>` | — | `{ bundle
|
|
18
|
-
| `POST /bundles/<id>/ack` | `{ "summary"?: string }` | `
|
|
19
|
-
| `POST /register` | `{ id, agent, cwd, title? }` | `
|
|
20
|
-
| `POST /heartbeat` | `{ id }` | `{
|
|
21
|
-
| `GET /adapter/<id>/events` | — | Server-sent events: `event: deliver` / `data: { "bundleId": … }`, plus a comment ping every 15 s |
|
|
42
|
+
| `GET` / `POST /health` | — | `HealthResponse` |
|
|
43
|
+
| `GET` / `POST /sessions` | — | `Session[]`: live push sessions, herdr panes (`method: "herdr"`), then sessions seen by the prompt hook outside herdr (`method: "next-prompt"`) |
|
|
44
|
+
| `POST /bundles` | `multipart/form-data`: field `bundle` = JSON `BundleInput`; files `page.md`, `screenshot.png`, optional `snapshot.html`, `crops/A<n>.png` | `201 SendResponse` (`{ id, status }`); `400 ErrorResponse` for an invalid bundle or file name |
|
|
45
|
+
| `POST /bundles/<id>/status`, `GET /bundles/<id>` | — | `BundleStatusResponse` (`{ bundle, status }`; `status` is `null` if the status file is missing or unreadable); `404` if unknown |
|
|
46
|
+
| `POST /bundles/<id>/ack` | `AckRequest` (`{ "summary"?: string }`) | `OkResponse`; `404` if unknown or busy |
|
|
47
|
+
| `POST /register` | `{ id, agent, cwd, title? }` | `OkResponse`; registers a push adapter (`400` for an invalid agent name) |
|
|
48
|
+
| `POST /heartbeat` | `{ id }` | `OkResponse`, or `404 {"ok":false}` if not registered |
|
|
49
|
+
| `GET /adapter/<id>/events` | — | Server-sent events: a `: connected` comment, then `event: deliver` / `data: { "bundleId": … }`, plus a comment ping every 15 s; `404 {"error":"not registered"}` if not registered |
|
|
22
50
|
|
|
23
|
-
|
|
51
|
+
Every error body is `ErrorResponse` (`{ "error": string }`), except `/heartbeat`'s `404 {"ok":false}` and a body over the 50 MB cap, which the server refuses before the bridge handles the request.
|
|
24
52
|
|
|
25
53
|
## Bundle folder
|
|
26
54
|
|
|
27
|
-
Each bundle is written to `$ANYNOTATE_HOME/inbox/<id>/` (default `~/.anynotate`)
|
|
55
|
+
Each bundle is written to `$ANYNOTATE_HOME/inbox/<id>/` (default `~/.anynotate`); older bundles move to `$ANYNOTATE_HOME/archive/`. Ids match `BUNDLE_ID`, e.g. `2026-09-24T153200-tomato-soup-recipes`.
|
|
28
56
|
|
|
29
57
|
| File | Contents |
|
|
30
58
|
|---|---|
|
|
@@ -38,6 +66,19 @@ Each bundle is written to `$ANYNOTATE_HOME/inbox/<id>/` (default `~/.anynotate`)
|
|
|
38
66
|
|
|
39
67
|
`$ANYNOTATE_HOME/inbox/latest` points at the newest bundle.
|
|
40
68
|
|
|
41
|
-
##
|
|
69
|
+
## Evolution
|
|
70
|
+
|
|
71
|
+
- Additive changes — optional fields, new endpoints — keep `PROTOCOL_VERSION`. Both sides ignore fields they don't know.
|
|
72
|
+
- Removing, renaming or changing the meaning of anything bumps `PROTOCOL_VERSION`; the bridge keeps accepting the previous version (`PROTOCOL_MIN`) for at least one release.
|
|
73
|
+
- The bundle format has its own `v` (currently `1`); the agent-facing `README.md` stays human-readable.
|
|
74
|
+
|
|
75
|
+
## Threat model
|
|
42
76
|
|
|
43
|
-
|
|
77
|
+
- **Web pages** can reach `127.0.0.1` from the user's browser. They are refused by `Origin` (including preflights), and DNS-rebinding pages by `Host`; a web request without an `Origin` (such as an `<img>` GET) fails the token check.
|
|
78
|
+
- **Other users, and sandboxed processes that cannot read `$ANYNOTATE_HOME/token`,** can reach the loopback port and can forge any `Origin`, but the token is required on every route except `/health`, and `$ANYNOTATE_HOME` and its contents are owner-only. They are refused.
|
|
79
|
+
- **Processes running as the user** can read the token file or run the native host themselves; the bridge does not defend against code already running as the user.
|
|
80
|
+
- **Bundle handling**: file names are allow-listed, ids are validated, bodies are capped at 50 MB, and the bridge loads no remote code.
|
|
81
|
+
- **The token** is compared in constant time. The extension receives it only from the native host, which Chrome starts only for allow-listed extension ids.
|
|
82
|
+
- **Extension ids** are what the native host trusts. An extension the user side-loads in developer mode with a copied public key gets the same id and is treated as Anynotate.
|
|
83
|
+
- **A compromised extension renderer** can change the stored bridge URL, but the extension only ever sends the token to `127.0.0.1`.
|
|
84
|
+
- **While the bridge is not running**, another local user could listen on its port and receive the extension's token. Keep the bridge running (launchd restarts it), and treat the token as exposed if that may have happened: delete `~/.anynotate/token` and restart the bridge to rotate it.
|
package/README.md
CHANGED
|
@@ -12,6 +12,8 @@ import { BundleInput, type Session } from "@anynotate/protocol";
|
|
|
12
12
|
const parsed = BundleInput.safeParse(payload);
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
+
Request/response examples for the extension-facing endpoints: `import { conformance } from "@anynotate/protocol/fixtures"`.
|
|
16
|
+
|
|
15
17
|
Versions below 1.0 may change the wire format between minor releases.
|
|
16
18
|
|
|
17
19
|
## License
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import { type BundleInput } from "./index.js";
|
|
3
|
+
export declare const FIXTURE_EXTENSION_ORIGIN = "chrome-extension://abcdefghijklmnopabcdefghijklmnop";
|
|
4
|
+
export declare const EXAMPLE_BUNDLE_ID = "2026-09-24T153200-tomato-soup-recipes";
|
|
5
|
+
export declare const exampleBundleInput: BundleInput;
|
|
6
|
+
export declare const examplePage = "# Tomato soup\n\nstir in \u27E6A1\u27E7200 ml cream before serving\n";
|
|
7
|
+
export declare const responseSchemas: {
|
|
8
|
+
HealthResponse: z.ZodObject<{
|
|
9
|
+
ok: z.ZodLiteral<true>;
|
|
10
|
+
bridgeVersion: z.ZodString;
|
|
11
|
+
protocol: z.ZodObject<{
|
|
12
|
+
version: z.ZodNumber;
|
|
13
|
+
min: z.ZodNumber;
|
|
14
|
+
}, z.core.$strip>;
|
|
15
|
+
}, z.core.$strip>;
|
|
16
|
+
SessionsResponse: z.ZodArray<z.ZodObject<{
|
|
17
|
+
id: z.ZodString;
|
|
18
|
+
agent: z.ZodString;
|
|
19
|
+
cwd: z.ZodString;
|
|
20
|
+
title: z.ZodString;
|
|
21
|
+
method: z.ZodEnum<{
|
|
22
|
+
push: "push";
|
|
23
|
+
herdr: "herdr";
|
|
24
|
+
"next-prompt": "next-prompt";
|
|
25
|
+
}>;
|
|
26
|
+
pane: z.ZodOptional<z.ZodString>;
|
|
27
|
+
sessionIds: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
28
|
+
}, z.core.$strip>>;
|
|
29
|
+
SendResponse: z.ZodObject<{
|
|
30
|
+
id: z.ZodString;
|
|
31
|
+
status: z.ZodObject<{
|
|
32
|
+
state: z.ZodEnum<{
|
|
33
|
+
queued: "queued";
|
|
34
|
+
delivered: "delivered";
|
|
35
|
+
acked: "acked";
|
|
36
|
+
}>;
|
|
37
|
+
at: z.ZodString;
|
|
38
|
+
via: z.ZodOptional<z.ZodEnum<{
|
|
39
|
+
push: "push";
|
|
40
|
+
herdr: "herdr";
|
|
41
|
+
hook: "hook";
|
|
42
|
+
pull: "pull";
|
|
43
|
+
}>>;
|
|
44
|
+
session: z.ZodOptional<z.ZodString>;
|
|
45
|
+
summary: z.ZodOptional<z.ZodString>;
|
|
46
|
+
note: z.ZodOptional<z.ZodString>;
|
|
47
|
+
}, z.core.$strip>;
|
|
48
|
+
}, z.core.$strip>;
|
|
49
|
+
BundleStatusResponse: z.ZodObject<{
|
|
50
|
+
bundle: z.ZodObject<{
|
|
51
|
+
v: z.ZodLiteral<1>;
|
|
52
|
+
url: z.ZodString;
|
|
53
|
+
title: z.ZodString;
|
|
54
|
+
sentAt: z.ZodISODateTime;
|
|
55
|
+
target: z.ZodObject<{
|
|
56
|
+
agent: z.ZodString;
|
|
57
|
+
sessionId: z.ZodOptional<z.ZodString>;
|
|
58
|
+
cwd: z.ZodOptional<z.ZodString>;
|
|
59
|
+
pane: z.ZodOptional<z.ZodString>;
|
|
60
|
+
}, z.core.$strip>;
|
|
61
|
+
overall: z.ZodOptional<z.ZodString>;
|
|
62
|
+
annotations: z.ZodArray<z.ZodObject<{
|
|
63
|
+
id: z.ZodString;
|
|
64
|
+
kind: z.ZodEnum<{
|
|
65
|
+
text: "text";
|
|
66
|
+
element: "element";
|
|
67
|
+
}>;
|
|
68
|
+
comment: z.ZodString;
|
|
69
|
+
intent: z.ZodOptional<z.ZodEnum<{
|
|
70
|
+
question: "question";
|
|
71
|
+
change: "change";
|
|
72
|
+
bug: "bug";
|
|
73
|
+
note: "note";
|
|
74
|
+
}>>;
|
|
75
|
+
anchor: z.ZodObject<{
|
|
76
|
+
quote: z.ZodOptional<z.ZodObject<{
|
|
77
|
+
exact: z.ZodString;
|
|
78
|
+
prefix: z.ZodString;
|
|
79
|
+
suffix: z.ZodString;
|
|
80
|
+
}, z.core.$strip>>;
|
|
81
|
+
position: z.ZodOptional<z.ZodObject<{
|
|
82
|
+
start: z.ZodNumber;
|
|
83
|
+
end: z.ZodNumber;
|
|
84
|
+
}, z.core.$strip>>;
|
|
85
|
+
css: z.ZodString;
|
|
86
|
+
path: z.ZodArray<z.ZodString>;
|
|
87
|
+
near: z.ZodString;
|
|
88
|
+
hosts: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
89
|
+
}, z.core.$strip>;
|
|
90
|
+
element: z.ZodOptional<z.ZodObject<{
|
|
91
|
+
tag: z.ZodString;
|
|
92
|
+
role: z.ZodOptional<z.ZodString>;
|
|
93
|
+
name: z.ZodOptional<z.ZodString>;
|
|
94
|
+
text: z.ZodString;
|
|
95
|
+
html: z.ZodString;
|
|
96
|
+
attrs: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
97
|
+
}, z.core.$strip>>;
|
|
98
|
+
box: z.ZodObject<{
|
|
99
|
+
x: z.ZodNumber;
|
|
100
|
+
y: z.ZodNumber;
|
|
101
|
+
w: z.ZodNumber;
|
|
102
|
+
h: z.ZodNumber;
|
|
103
|
+
}, z.core.$strip>;
|
|
104
|
+
viewport: z.ZodObject<{
|
|
105
|
+
w: z.ZodNumber;
|
|
106
|
+
h: z.ZodNumber;
|
|
107
|
+
dpr: z.ZodNumber;
|
|
108
|
+
scrollY: z.ZodNumber;
|
|
109
|
+
}, z.core.$strip>;
|
|
110
|
+
crop: z.ZodString;
|
|
111
|
+
}, z.core.$strip>>;
|
|
112
|
+
id: z.ZodString;
|
|
113
|
+
files: z.ZodObject<{
|
|
114
|
+
page: z.ZodString;
|
|
115
|
+
screenshot: z.ZodString;
|
|
116
|
+
snapshot: z.ZodOptional<z.ZodString>;
|
|
117
|
+
}, z.core.$strip>;
|
|
118
|
+
}, z.core.$strip>;
|
|
119
|
+
status: z.ZodNullable<z.ZodObject<{
|
|
120
|
+
state: z.ZodEnum<{
|
|
121
|
+
queued: "queued";
|
|
122
|
+
delivered: "delivered";
|
|
123
|
+
acked: "acked";
|
|
124
|
+
}>;
|
|
125
|
+
at: z.ZodString;
|
|
126
|
+
via: z.ZodOptional<z.ZodEnum<{
|
|
127
|
+
push: "push";
|
|
128
|
+
herdr: "herdr";
|
|
129
|
+
hook: "hook";
|
|
130
|
+
pull: "pull";
|
|
131
|
+
}>>;
|
|
132
|
+
session: z.ZodOptional<z.ZodString>;
|
|
133
|
+
summary: z.ZodOptional<z.ZodString>;
|
|
134
|
+
note: z.ZodOptional<z.ZodString>;
|
|
135
|
+
}, z.core.$strip>>;
|
|
136
|
+
}, z.core.$strip>;
|
|
137
|
+
OkResponse: z.ZodObject<{
|
|
138
|
+
ok: z.ZodLiteral<true>;
|
|
139
|
+
}, z.core.$strip>;
|
|
140
|
+
ErrorResponse: z.ZodObject<{
|
|
141
|
+
error: z.ZodString;
|
|
142
|
+
}, z.core.$strip>;
|
|
143
|
+
};
|
|
144
|
+
export type SchemaName = keyof typeof responseSchemas;
|
|
145
|
+
export type Exchange = {
|
|
146
|
+
name: string;
|
|
147
|
+
request: {
|
|
148
|
+
method: "GET" | "POST";
|
|
149
|
+
path: string;
|
|
150
|
+
auth: "extension" | "extension-no-token" | "token" | "none" | "foreign-origin";
|
|
151
|
+
host?: string;
|
|
152
|
+
json?: unknown;
|
|
153
|
+
bundle?: {
|
|
154
|
+
input: BundleInput;
|
|
155
|
+
files: Record<string, string>;
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
status: number;
|
|
159
|
+
response: {
|
|
160
|
+
schema: SchemaName;
|
|
161
|
+
example: unknown;
|
|
162
|
+
};
|
|
163
|
+
};
|
|
164
|
+
export declare const conformance: Exchange[];
|
package/dist/fixtures.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { BundleStatusResponse, ErrorResponse, HealthResponse, OkResponse, SendResponse, SessionsResponse, } from "./index.js";
|
|
2
|
+
// Shared request/response examples. The bridge's tests replay every exchange against a live bridge; client
|
|
3
|
+
// tests check that they make the extension exchanges with exactly this method and path and can parse the examples.
|
|
4
|
+
export const FIXTURE_EXTENSION_ORIGIN = "chrome-extension://abcdefghijklmnopabcdefghijklmnop";
|
|
5
|
+
export const EXAMPLE_BUNDLE_ID = "2026-09-24T153200-tomato-soup-recipes";
|
|
6
|
+
export const exampleBundleInput = {
|
|
7
|
+
v: 1,
|
|
8
|
+
url: "https://recipes.example.com/tomato-soup",
|
|
9
|
+
title: "Tomato soup – Recipes",
|
|
10
|
+
sentAt: "2026-09-24T15:32:00.000Z",
|
|
11
|
+
target: { agent: "claude", sessionId: "s-1", cwd: "/tmp/repo" },
|
|
12
|
+
overall: "can we make this vegan?",
|
|
13
|
+
annotations: [{
|
|
14
|
+
id: "A1", kind: "text", comment: "is there a substitute for cream?", intent: "question",
|
|
15
|
+
anchor: {
|
|
16
|
+
quote: { exact: "200 ml cream", prefix: "stir in ", suffix: " before serving" },
|
|
17
|
+
css: "section#ingredients > li:nth-child(3)", path: ["main", "section#ingredients", "ul"], near: "Ingredients",
|
|
18
|
+
},
|
|
19
|
+
box: { x: 10, y: 20, w: 100, h: 18 },
|
|
20
|
+
viewport: { w: 1280, h: 800, dpr: 2, scrollY: 0 },
|
|
21
|
+
crop: "crops/A1.png",
|
|
22
|
+
}],
|
|
23
|
+
};
|
|
24
|
+
export const examplePage = "# Tomato soup\n\nstir in ⟦A1⟧200 ml cream before serving\n";
|
|
25
|
+
export const responseSchemas = {
|
|
26
|
+
HealthResponse, SessionsResponse, SendResponse, BundleStatusResponse, OkResponse, ErrorResponse,
|
|
27
|
+
};
|
|
28
|
+
const health = { ok: true, bridgeVersion: "0.2.0", protocol: { version: 1, min: 1 } };
|
|
29
|
+
const queued = { state: "queued", at: "2026-09-24T15:32:01.000Z" };
|
|
30
|
+
const exampleBundle = { ...exampleBundleInput, id: EXAMPLE_BUNDLE_ID, files: { page: "page.md", screenshot: "screenshot.png" } };
|
|
31
|
+
export const conformance = [
|
|
32
|
+
{ name: "health-extension", request: { method: "POST", path: "/health", auth: "extension" }, status: 200, response: { schema: "HealthResponse", example: health } },
|
|
33
|
+
{ name: "health-probe", request: { method: "GET", path: "/health", auth: "none" }, status: 200, response: { schema: "HealthResponse", example: health } },
|
|
34
|
+
{ name: "sessions-extension", request: { method: "POST", path: "/sessions", auth: "extension" }, status: 200, response: { schema: "SessionsResponse", example: [{ id: "w1:p2", agent: "claude", cwd: "/home/me/project", title: "shell", method: "herdr", pane: "w1:p2" }] } },
|
|
35
|
+
{ name: "sessions-token", request: { method: "GET", path: "/sessions", auth: "token" }, status: 200, response: { schema: "SessionsResponse", example: [] } },
|
|
36
|
+
{ name: "sessions-extension-no-token", request: { method: "POST", path: "/sessions", auth: "extension-no-token" }, status: 401, response: { schema: "ErrorResponse", example: { error: "bad token" } } },
|
|
37
|
+
{ name: "sessions-unauthenticated", request: { method: "POST", path: "/sessions", auth: "none" }, status: 401, response: { schema: "ErrorResponse", example: { error: "bad token" } } },
|
|
38
|
+
{ name: "sessions-foreign-origin", request: { method: "POST", path: "/sessions", auth: "foreign-origin" }, status: 403, response: { schema: "ErrorResponse", example: { error: "forbidden origin" } } },
|
|
39
|
+
{ name: "health-wrong-host", request: { method: "GET", path: "/health", auth: "none", host: "evil.example" }, status: 403, response: { schema: "ErrorResponse", example: { error: "bad host" } } },
|
|
40
|
+
{ name: "send-bundle", request: { method: "POST", path: "/bundles", auth: "extension", bundle: { input: exampleBundleInput, files: { "page.md": examplePage } } }, status: 201, response: { schema: "SendResponse", example: { id: EXAMPLE_BUNDLE_ID, status: queued } } },
|
|
41
|
+
{ name: "bundle-status-extension", request: { method: "POST", path: "/bundles/:id/status", auth: "extension" }, status: 200, response: { schema: "BundleStatusResponse", example: { bundle: exampleBundle, status: queued } } },
|
|
42
|
+
{ name: "bundle-status-token", request: { method: "GET", path: "/bundles/:id", auth: "token" }, status: 200, response: { schema: "BundleStatusResponse", example: { bundle: exampleBundle, status: queued } } },
|
|
43
|
+
{ name: "ack", request: { method: "POST", path: "/bundles/:id/ack", auth: "token", json: { summary: "suggested oat cream" } }, status: 200, response: { schema: "OkResponse", example: { ok: true } } },
|
|
44
|
+
{ name: "bundle-status-unknown", request: { method: "POST", path: "/bundles/2026-01-01T000000-missing/status", auth: "extension" }, status: 404, response: { schema: "ErrorResponse", example: { error: "not found" } } },
|
|
45
|
+
];
|
package/dist/index.d.ts
CHANGED
|
@@ -234,3 +234,158 @@ export declare const Session: z.ZodObject<{
|
|
|
234
234
|
sessionIds: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
235
235
|
}, z.core.$strip>;
|
|
236
236
|
export type Session = z.infer<typeof Session>;
|
|
237
|
+
export declare const PROTOCOL_VERSION = 1;
|
|
238
|
+
export declare const PROTOCOL_MIN = 1;
|
|
239
|
+
export declare const HealthResponse: z.ZodObject<{
|
|
240
|
+
ok: z.ZodLiteral<true>;
|
|
241
|
+
bridgeVersion: z.ZodString;
|
|
242
|
+
protocol: z.ZodObject<{
|
|
243
|
+
version: z.ZodNumber;
|
|
244
|
+
min: z.ZodNumber;
|
|
245
|
+
}, z.core.$strip>;
|
|
246
|
+
}, z.core.$strip>;
|
|
247
|
+
export type HealthResponse = z.infer<typeof HealthResponse>;
|
|
248
|
+
export declare const SessionsResponse: z.ZodArray<z.ZodObject<{
|
|
249
|
+
id: z.ZodString;
|
|
250
|
+
agent: z.ZodString;
|
|
251
|
+
cwd: z.ZodString;
|
|
252
|
+
title: z.ZodString;
|
|
253
|
+
method: z.ZodEnum<{
|
|
254
|
+
push: "push";
|
|
255
|
+
herdr: "herdr";
|
|
256
|
+
"next-prompt": "next-prompt";
|
|
257
|
+
}>;
|
|
258
|
+
pane: z.ZodOptional<z.ZodString>;
|
|
259
|
+
sessionIds: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
260
|
+
}, z.core.$strip>>;
|
|
261
|
+
export type SessionsResponse = z.infer<typeof SessionsResponse>;
|
|
262
|
+
export declare const SendResponse: z.ZodObject<{
|
|
263
|
+
id: z.ZodString;
|
|
264
|
+
status: z.ZodObject<{
|
|
265
|
+
state: z.ZodEnum<{
|
|
266
|
+
queued: "queued";
|
|
267
|
+
delivered: "delivered";
|
|
268
|
+
acked: "acked";
|
|
269
|
+
}>;
|
|
270
|
+
at: z.ZodString;
|
|
271
|
+
via: z.ZodOptional<z.ZodEnum<{
|
|
272
|
+
push: "push";
|
|
273
|
+
herdr: "herdr";
|
|
274
|
+
hook: "hook";
|
|
275
|
+
pull: "pull";
|
|
276
|
+
}>>;
|
|
277
|
+
session: z.ZodOptional<z.ZodString>;
|
|
278
|
+
summary: z.ZodOptional<z.ZodString>;
|
|
279
|
+
note: z.ZodOptional<z.ZodString>;
|
|
280
|
+
}, z.core.$strip>;
|
|
281
|
+
}, z.core.$strip>;
|
|
282
|
+
export type SendResponse = z.infer<typeof SendResponse>;
|
|
283
|
+
export declare const BundleStatusResponse: z.ZodObject<{
|
|
284
|
+
bundle: z.ZodObject<{
|
|
285
|
+
v: z.ZodLiteral<1>;
|
|
286
|
+
url: z.ZodString;
|
|
287
|
+
title: z.ZodString;
|
|
288
|
+
sentAt: z.ZodISODateTime;
|
|
289
|
+
target: z.ZodObject<{
|
|
290
|
+
agent: z.ZodString;
|
|
291
|
+
sessionId: z.ZodOptional<z.ZodString>;
|
|
292
|
+
cwd: z.ZodOptional<z.ZodString>;
|
|
293
|
+
pane: z.ZodOptional<z.ZodString>;
|
|
294
|
+
}, z.core.$strip>;
|
|
295
|
+
overall: z.ZodOptional<z.ZodString>;
|
|
296
|
+
annotations: z.ZodArray<z.ZodObject<{
|
|
297
|
+
id: z.ZodString;
|
|
298
|
+
kind: z.ZodEnum<{
|
|
299
|
+
text: "text";
|
|
300
|
+
element: "element";
|
|
301
|
+
}>;
|
|
302
|
+
comment: z.ZodString;
|
|
303
|
+
intent: z.ZodOptional<z.ZodEnum<{
|
|
304
|
+
question: "question";
|
|
305
|
+
change: "change";
|
|
306
|
+
bug: "bug";
|
|
307
|
+
note: "note";
|
|
308
|
+
}>>;
|
|
309
|
+
anchor: z.ZodObject<{
|
|
310
|
+
quote: z.ZodOptional<z.ZodObject<{
|
|
311
|
+
exact: z.ZodString;
|
|
312
|
+
prefix: z.ZodString;
|
|
313
|
+
suffix: z.ZodString;
|
|
314
|
+
}, z.core.$strip>>;
|
|
315
|
+
position: z.ZodOptional<z.ZodObject<{
|
|
316
|
+
start: z.ZodNumber;
|
|
317
|
+
end: z.ZodNumber;
|
|
318
|
+
}, z.core.$strip>>;
|
|
319
|
+
css: z.ZodString;
|
|
320
|
+
path: z.ZodArray<z.ZodString>;
|
|
321
|
+
near: z.ZodString;
|
|
322
|
+
hosts: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
323
|
+
}, z.core.$strip>;
|
|
324
|
+
element: z.ZodOptional<z.ZodObject<{
|
|
325
|
+
tag: z.ZodString;
|
|
326
|
+
role: z.ZodOptional<z.ZodString>;
|
|
327
|
+
name: z.ZodOptional<z.ZodString>;
|
|
328
|
+
text: z.ZodString;
|
|
329
|
+
html: z.ZodString;
|
|
330
|
+
attrs: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
331
|
+
}, z.core.$strip>>;
|
|
332
|
+
box: z.ZodObject<{
|
|
333
|
+
x: z.ZodNumber;
|
|
334
|
+
y: z.ZodNumber;
|
|
335
|
+
w: z.ZodNumber;
|
|
336
|
+
h: z.ZodNumber;
|
|
337
|
+
}, z.core.$strip>;
|
|
338
|
+
viewport: z.ZodObject<{
|
|
339
|
+
w: z.ZodNumber;
|
|
340
|
+
h: z.ZodNumber;
|
|
341
|
+
dpr: z.ZodNumber;
|
|
342
|
+
scrollY: z.ZodNumber;
|
|
343
|
+
}, z.core.$strip>;
|
|
344
|
+
crop: z.ZodString;
|
|
345
|
+
}, z.core.$strip>>;
|
|
346
|
+
id: z.ZodString;
|
|
347
|
+
files: z.ZodObject<{
|
|
348
|
+
page: z.ZodString;
|
|
349
|
+
screenshot: z.ZodString;
|
|
350
|
+
snapshot: z.ZodOptional<z.ZodString>;
|
|
351
|
+
}, z.core.$strip>;
|
|
352
|
+
}, z.core.$strip>;
|
|
353
|
+
status: z.ZodNullable<z.ZodObject<{
|
|
354
|
+
state: z.ZodEnum<{
|
|
355
|
+
queued: "queued";
|
|
356
|
+
delivered: "delivered";
|
|
357
|
+
acked: "acked";
|
|
358
|
+
}>;
|
|
359
|
+
at: z.ZodString;
|
|
360
|
+
via: z.ZodOptional<z.ZodEnum<{
|
|
361
|
+
push: "push";
|
|
362
|
+
herdr: "herdr";
|
|
363
|
+
hook: "hook";
|
|
364
|
+
pull: "pull";
|
|
365
|
+
}>>;
|
|
366
|
+
session: z.ZodOptional<z.ZodString>;
|
|
367
|
+
summary: z.ZodOptional<z.ZodString>;
|
|
368
|
+
note: z.ZodOptional<z.ZodString>;
|
|
369
|
+
}, z.core.$strip>>;
|
|
370
|
+
}, z.core.$strip>;
|
|
371
|
+
export type BundleStatusResponse = z.infer<typeof BundleStatusResponse>;
|
|
372
|
+
export declare const AckRequest: z.ZodObject<{
|
|
373
|
+
summary: z.ZodOptional<z.ZodString>;
|
|
374
|
+
}, z.core.$strip>;
|
|
375
|
+
export type AckRequest = z.infer<typeof AckRequest>;
|
|
376
|
+
export declare const OkResponse: z.ZodObject<{
|
|
377
|
+
ok: z.ZodLiteral<true>;
|
|
378
|
+
}, z.core.$strip>;
|
|
379
|
+
export type OkResponse = z.infer<typeof OkResponse>;
|
|
380
|
+
export declare const ErrorResponse: z.ZodObject<{
|
|
381
|
+
error: z.ZodString;
|
|
382
|
+
}, z.core.$strip>;
|
|
383
|
+
export type ErrorResponse = z.infer<typeof ErrorResponse>;
|
|
384
|
+
export type Compatibility = "ok" | "bridge-too-old" | "extension-too-old";
|
|
385
|
+
export declare function compatibility(client: {
|
|
386
|
+
min: number;
|
|
387
|
+
max: number;
|
|
388
|
+
}, bridge: {
|
|
389
|
+
version: number;
|
|
390
|
+
min: number;
|
|
391
|
+
}): Compatibility;
|
package/dist/index.js
CHANGED
|
@@ -68,3 +68,26 @@ export const Session = z.object({
|
|
|
68
68
|
pane: z.string().optional(),
|
|
69
69
|
sessionIds: z.array(z.string()).optional(),
|
|
70
70
|
});
|
|
71
|
+
// Protocol v1. Additive changes (optional fields, new endpoints) keep the version; removals, renames and
|
|
72
|
+
// semantic changes bump PROTOCOL_VERSION, and the bridge keeps accepting the previous version for a release.
|
|
73
|
+
export const PROTOCOL_VERSION = 1;
|
|
74
|
+
export const PROTOCOL_MIN = 1;
|
|
75
|
+
export const HealthResponse = z.object({
|
|
76
|
+
ok: z.literal(true),
|
|
77
|
+
bridgeVersion: z.string(),
|
|
78
|
+
protocol: z.object({ version: z.number().int().positive(), min: z.number().int().positive() }),
|
|
79
|
+
});
|
|
80
|
+
export const SessionsResponse = z.array(Session);
|
|
81
|
+
export const SendResponse = z.object({ id: z.string().regex(BUNDLE_ID), status: Status });
|
|
82
|
+
export const BundleStatusResponse = z.object({ bundle: Bundle, status: Status.nullable() });
|
|
83
|
+
export const AckRequest = z.object({ summary: z.string().optional() });
|
|
84
|
+
export const OkResponse = z.object({ ok: z.literal(true) });
|
|
85
|
+
export const ErrorResponse = z.object({ error: z.string() });
|
|
86
|
+
// A client supports protocol versions [min, max]; a bridge speaks `version` and still accepts down to `min`.
|
|
87
|
+
export function compatibility(client, bridge) {
|
|
88
|
+
if (bridge.version < client.min)
|
|
89
|
+
return "bridge-too-old";
|
|
90
|
+
if (client.max < bridge.min)
|
|
91
|
+
return "extension-too-old";
|
|
92
|
+
return "ok";
|
|
93
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anynotate/protocol",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Wire types and zod schemas shared by Anynotate clients and the Anynotate bridge.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Kun Xu",
|
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
"types": "./dist/index.d.ts",
|
|
17
17
|
"import": "./dist/index.js",
|
|
18
18
|
"default": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./fixtures": {
|
|
21
|
+
"bun": "./src/fixtures.ts",
|
|
22
|
+
"types": "./dist/fixtures.d.ts",
|
|
23
|
+
"import": "./dist/fixtures.js",
|
|
24
|
+
"default": "./dist/fixtures.js"
|
|
19
25
|
}
|
|
20
26
|
},
|
|
21
27
|
"files": ["dist", "src", "PROTOCOL.md", "README.md", "LICENSE"],
|
package/src/fixtures.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
BundleStatusResponse, type BundleInput, ErrorResponse, HealthResponse, OkResponse, SendResponse, SessionsResponse,
|
|
4
|
+
} from "./index.js";
|
|
5
|
+
|
|
6
|
+
// Shared request/response examples. The bridge's tests replay every exchange against a live bridge; client
|
|
7
|
+
// tests check that they make the extension exchanges with exactly this method and path and can parse the examples.
|
|
8
|
+
export const FIXTURE_EXTENSION_ORIGIN = "chrome-extension://abcdefghijklmnopabcdefghijklmnop";
|
|
9
|
+
export const EXAMPLE_BUNDLE_ID = "2026-09-24T153200-tomato-soup-recipes";
|
|
10
|
+
|
|
11
|
+
export const exampleBundleInput: BundleInput = {
|
|
12
|
+
v: 1,
|
|
13
|
+
url: "https://recipes.example.com/tomato-soup",
|
|
14
|
+
title: "Tomato soup – Recipes",
|
|
15
|
+
sentAt: "2026-09-24T15:32:00.000Z",
|
|
16
|
+
target: { agent: "claude", sessionId: "s-1", cwd: "/tmp/repo" },
|
|
17
|
+
overall: "can we make this vegan?",
|
|
18
|
+
annotations: [{
|
|
19
|
+
id: "A1", kind: "text", comment: "is there a substitute for cream?", intent: "question",
|
|
20
|
+
anchor: {
|
|
21
|
+
quote: { exact: "200 ml cream", prefix: "stir in ", suffix: " before serving" },
|
|
22
|
+
css: "section#ingredients > li:nth-child(3)", path: ["main", "section#ingredients", "ul"], near: "Ingredients",
|
|
23
|
+
},
|
|
24
|
+
box: { x: 10, y: 20, w: 100, h: 18 },
|
|
25
|
+
viewport: { w: 1280, h: 800, dpr: 2, scrollY: 0 },
|
|
26
|
+
crop: "crops/A1.png",
|
|
27
|
+
}],
|
|
28
|
+
};
|
|
29
|
+
export const examplePage = "# Tomato soup\n\nstir in ⟦A1⟧200 ml cream before serving\n";
|
|
30
|
+
|
|
31
|
+
export const responseSchemas = {
|
|
32
|
+
HealthResponse, SessionsResponse, SendResponse, BundleStatusResponse, OkResponse, ErrorResponse,
|
|
33
|
+
} satisfies Record<string, z.ZodType>;
|
|
34
|
+
export type SchemaName = keyof typeof responseSchemas;
|
|
35
|
+
|
|
36
|
+
export type Exchange = {
|
|
37
|
+
name: string;
|
|
38
|
+
request: {
|
|
39
|
+
method: "GET" | "POST";
|
|
40
|
+
path: string;
|
|
41
|
+
// extension = allowed Origin and token; extension-no-token = allowed Origin only; token = X-Anynotate-Token, no Origin;
|
|
42
|
+
// foreign-origin = a web page.
|
|
43
|
+
auth: "extension" | "extension-no-token" | "token" | "none" | "foreign-origin";
|
|
44
|
+
host?: string;
|
|
45
|
+
json?: unknown;
|
|
46
|
+
bundle?: { input: BundleInput; files: Record<string, string> };
|
|
47
|
+
};
|
|
48
|
+
status: number;
|
|
49
|
+
response: { schema: SchemaName; example: unknown };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const health = { ok: true, bridgeVersion: "0.2.0", protocol: { version: 1, min: 1 } };
|
|
53
|
+
const queued = { state: "queued", at: "2026-09-24T15:32:01.000Z" };
|
|
54
|
+
const exampleBundle = { ...exampleBundleInput, id: EXAMPLE_BUNDLE_ID, files: { page: "page.md", screenshot: "screenshot.png" } };
|
|
55
|
+
|
|
56
|
+
export const conformance: Exchange[] = [
|
|
57
|
+
{ name: "health-extension", request: { method: "POST", path: "/health", auth: "extension" }, status: 200, response: { schema: "HealthResponse", example: health } },
|
|
58
|
+
{ name: "health-probe", request: { method: "GET", path: "/health", auth: "none" }, status: 200, response: { schema: "HealthResponse", example: health } },
|
|
59
|
+
{ name: "sessions-extension", request: { method: "POST", path: "/sessions", auth: "extension" }, status: 200, response: { schema: "SessionsResponse", example: [{ id: "w1:p2", agent: "claude", cwd: "/home/me/project", title: "shell", method: "herdr", pane: "w1:p2" }] } },
|
|
60
|
+
{ name: "sessions-token", request: { method: "GET", path: "/sessions", auth: "token" }, status: 200, response: { schema: "SessionsResponse", example: [] } },
|
|
61
|
+
{ name: "sessions-extension-no-token", request: { method: "POST", path: "/sessions", auth: "extension-no-token" }, status: 401, response: { schema: "ErrorResponse", example: { error: "bad token" } } },
|
|
62
|
+
{ name: "sessions-unauthenticated", request: { method: "POST", path: "/sessions", auth: "none" }, status: 401, response: { schema: "ErrorResponse", example: { error: "bad token" } } },
|
|
63
|
+
{ name: "sessions-foreign-origin", request: { method: "POST", path: "/sessions", auth: "foreign-origin" }, status: 403, response: { schema: "ErrorResponse", example: { error: "forbidden origin" } } },
|
|
64
|
+
{ name: "health-wrong-host", request: { method: "GET", path: "/health", auth: "none", host: "evil.example" }, status: 403, response: { schema: "ErrorResponse", example: { error: "bad host" } } },
|
|
65
|
+
{ name: "send-bundle", request: { method: "POST", path: "/bundles", auth: "extension", bundle: { input: exampleBundleInput, files: { "page.md": examplePage } } }, status: 201, response: { schema: "SendResponse", example: { id: EXAMPLE_BUNDLE_ID, status: queued } } },
|
|
66
|
+
{ name: "bundle-status-extension", request: { method: "POST", path: "/bundles/:id/status", auth: "extension" }, status: 200, response: { schema: "BundleStatusResponse", example: { bundle: exampleBundle, status: queued } } },
|
|
67
|
+
{ name: "bundle-status-token", request: { method: "GET", path: "/bundles/:id", auth: "token" }, status: 200, response: { schema: "BundleStatusResponse", example: { bundle: exampleBundle, status: queued } } },
|
|
68
|
+
{ name: "ack", request: { method: "POST", path: "/bundles/:id/ack", auth: "token", json: { summary: "suggested oat cream" } }, status: 200, response: { schema: "OkResponse", example: { ok: true } } },
|
|
69
|
+
{ name: "bundle-status-unknown", request: { method: "POST", path: "/bundles/2026-01-01T000000-missing/status", auth: "extension" }, status: 404, response: { schema: "ErrorResponse", example: { error: "not found" } } },
|
|
70
|
+
];
|
package/src/index.ts
CHANGED
|
@@ -86,3 +86,42 @@ export const Session = z.object({
|
|
|
86
86
|
sessionIds: z.array(z.string()).optional(),
|
|
87
87
|
});
|
|
88
88
|
export type Session = z.infer<typeof Session>;
|
|
89
|
+
|
|
90
|
+
// Protocol v1. Additive changes (optional fields, new endpoints) keep the version; removals, renames and
|
|
91
|
+
// semantic changes bump PROTOCOL_VERSION, and the bridge keeps accepting the previous version for a release.
|
|
92
|
+
export const PROTOCOL_VERSION = 1;
|
|
93
|
+
export const PROTOCOL_MIN = 1;
|
|
94
|
+
|
|
95
|
+
export const HealthResponse = z.object({
|
|
96
|
+
ok: z.literal(true),
|
|
97
|
+
bridgeVersion: z.string(),
|
|
98
|
+
protocol: z.object({ version: z.number().int().positive(), min: z.number().int().positive() }),
|
|
99
|
+
});
|
|
100
|
+
export type HealthResponse = z.infer<typeof HealthResponse>;
|
|
101
|
+
|
|
102
|
+
export const SessionsResponse = z.array(Session);
|
|
103
|
+
export type SessionsResponse = z.infer<typeof SessionsResponse>;
|
|
104
|
+
|
|
105
|
+
export const SendResponse = z.object({ id: z.string().regex(BUNDLE_ID), status: Status });
|
|
106
|
+
export type SendResponse = z.infer<typeof SendResponse>;
|
|
107
|
+
|
|
108
|
+
export const BundleStatusResponse = z.object({ bundle: Bundle, status: Status.nullable() });
|
|
109
|
+
export type BundleStatusResponse = z.infer<typeof BundleStatusResponse>;
|
|
110
|
+
|
|
111
|
+
export const AckRequest = z.object({ summary: z.string().optional() });
|
|
112
|
+
export type AckRequest = z.infer<typeof AckRequest>;
|
|
113
|
+
|
|
114
|
+
export const OkResponse = z.object({ ok: z.literal(true) });
|
|
115
|
+
export type OkResponse = z.infer<typeof OkResponse>;
|
|
116
|
+
|
|
117
|
+
export const ErrorResponse = z.object({ error: z.string() });
|
|
118
|
+
export type ErrorResponse = z.infer<typeof ErrorResponse>;
|
|
119
|
+
|
|
120
|
+
export type Compatibility = "ok" | "bridge-too-old" | "extension-too-old";
|
|
121
|
+
|
|
122
|
+
// A client supports protocol versions [min, max]; a bridge speaks `version` and still accepts down to `min`.
|
|
123
|
+
export function compatibility(client: { min: number; max: number }, bridge: { version: number; min: number }): Compatibility {
|
|
124
|
+
if (bridge.version < client.min) return "bridge-too-old";
|
|
125
|
+
if (client.max < bridge.min) return "extension-too-old";
|
|
126
|
+
return "ok";
|
|
127
|
+
}
|