@polycore/protocol 0.1.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 +24 -0
- package/package.json +39 -0
- package/src/envelope.ts +77 -0
- package/src/index.ts +27 -0
- package/src/messages.ts +150 -0
package/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# @polycore/protocol
|
|
2
|
+
|
|
3
|
+
The wire contract between the Polycore **control plane** and a **runner**: the
|
|
4
|
+
message schemas for the outbound WebSocket (join, dispatch, result, heartbeat,
|
|
5
|
+
and the join accept/reject frames) and the signed-envelope helpers that let a
|
|
6
|
+
runner verify a dispatch came from the control plane.
|
|
7
|
+
|
|
8
|
+
It is pure: [`zod`](https://zod.dev) schemas plus `node:crypto` for the HMAC
|
|
9
|
+
envelope, with no transport and no I/O. Both sides import it so a protocol
|
|
10
|
+
change is a single source of truth. `PROTOCOL_VERSION` is bumped whenever the
|
|
11
|
+
join/dispatch shape changes; a runner and control plane on different versions
|
|
12
|
+
refuse to connect.
|
|
13
|
+
|
|
14
|
+
This package is a dependency of [`@polycore/runner`](https://www.npmjs.com/package/@polycore/runner);
|
|
15
|
+
you normally consume it transitively, not directly.
|
|
16
|
+
|
|
17
|
+
## Exports
|
|
18
|
+
|
|
19
|
+
- `PROTOCOL_VERSION` and the message schemas + parsers (`parseRunnerToControl`,
|
|
20
|
+
`parseControlToRunner`, `serializeMessage`).
|
|
21
|
+
- The signed-envelope helpers for signing and verifying dispatch envelopes.
|
|
22
|
+
|
|
23
|
+
See `internal-docs/architecture.md` in the Polycore repo for how the two halves
|
|
24
|
+
connect.
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@polycore/protocol",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Polycore control-plane and runner wire contract: message schemas and signed-envelope helpers. Pure (zod + node:crypto), no transport.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./src/index.ts",
|
|
12
|
+
"import": "./src/index.ts",
|
|
13
|
+
"default": "./src/index.ts"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=22"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"zod": "4.4.3"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "25.6.2",
|
|
28
|
+
"typescript": "6.0.3",
|
|
29
|
+
"vitest": "4.1.6"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"typecheck": "tsc -p tsconfig.test.json",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"test:watch": "vitest"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/envelope.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A unit of work the control plane dispatches to a runner. The control
|
|
7
|
+
* plane signs it (HMAC-SHA256) with the runner's provisioned signing
|
|
8
|
+
* secret; the runner verifies the signature before executing. The secret
|
|
9
|
+
* is shared out-of-band at enrollment and never travels on the wire, so a
|
|
10
|
+
* compromised channel cannot forge a dispatch.
|
|
11
|
+
*/
|
|
12
|
+
export const dispatchEnvelopeSchema = z.object({
|
|
13
|
+
/** Correlation id; the runner echoes it back on the result. */
|
|
14
|
+
id: z.string().min(1),
|
|
15
|
+
/** Project to run against (keyed into the runner's config). */
|
|
16
|
+
project: z.string().min(1),
|
|
17
|
+
/** Connector capability to run, e.g. `firestore.count`. */
|
|
18
|
+
capability: z.string().min(1),
|
|
19
|
+
/** Environment to run against (keyed into the project's config). */
|
|
20
|
+
environment: z.string().min(1),
|
|
21
|
+
/** Connector arguments (validated again by the connector itself). */
|
|
22
|
+
args: z.unknown(),
|
|
23
|
+
/** Issue time (epoch ms) for staleness checks and audit. */
|
|
24
|
+
issuedAt: z.number().int().nonnegative(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export type DispatchEnvelope = z.infer<typeof dispatchEnvelopeSchema>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Deterministic JSON: object keys sorted recursively so both sides produce
|
|
31
|
+
* byte-identical input to the HMAC regardless of property insertion order.
|
|
32
|
+
*/
|
|
33
|
+
function stableStringify(value: unknown): string {
|
|
34
|
+
if (value === undefined) return "null";
|
|
35
|
+
if (value === null || typeof value !== "object") {
|
|
36
|
+
return JSON.stringify(value) ?? "null";
|
|
37
|
+
}
|
|
38
|
+
if (Array.isArray(value)) {
|
|
39
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
40
|
+
}
|
|
41
|
+
const obj = value as Record<string, unknown>;
|
|
42
|
+
const entries = Object.keys(obj)
|
|
43
|
+
.sort()
|
|
44
|
+
.map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key])}`);
|
|
45
|
+
return `{${entries.join(",")}}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function canonical(envelope: DispatchEnvelope): string {
|
|
49
|
+
return stableStringify({
|
|
50
|
+
id: envelope.id,
|
|
51
|
+
project: envelope.project,
|
|
52
|
+
capability: envelope.capability,
|
|
53
|
+
environment: envelope.environment,
|
|
54
|
+
args: envelope.args ?? null,
|
|
55
|
+
issuedAt: envelope.issuedAt,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Sign an envelope, returning a hex HMAC-SHA256 signature. */
|
|
60
|
+
export function signEnvelope(
|
|
61
|
+
envelope: DispatchEnvelope,
|
|
62
|
+
secret: string,
|
|
63
|
+
): string {
|
|
64
|
+
return createHmac("sha256", secret).update(canonical(envelope)).digest("hex");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Constant-time verify an envelope's signature against the shared secret. */
|
|
68
|
+
export function verifyEnvelope(
|
|
69
|
+
envelope: DispatchEnvelope,
|
|
70
|
+
signature: string,
|
|
71
|
+
secret: string,
|
|
72
|
+
): boolean {
|
|
73
|
+
const expected = Buffer.from(signEnvelope(envelope, secret), "utf8");
|
|
74
|
+
const actual = Buffer.from(signature, "utf8");
|
|
75
|
+
if (expected.length !== actual.length) return false;
|
|
76
|
+
return timingSafeEqual(expected, actual);
|
|
77
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type { DispatchEnvelope } from "./envelope.js";
|
|
2
|
+
export {
|
|
3
|
+
dispatchEnvelopeSchema,
|
|
4
|
+
signEnvelope,
|
|
5
|
+
verifyEnvelope,
|
|
6
|
+
} from "./envelope.js";
|
|
7
|
+
export type {
|
|
8
|
+
ConnectorDescriptor,
|
|
9
|
+
ControlToRunner,
|
|
10
|
+
DispatchMessage,
|
|
11
|
+
JoinedMessage,
|
|
12
|
+
JoinMessage,
|
|
13
|
+
ProjectAdvertisement,
|
|
14
|
+
RejectedMessage,
|
|
15
|
+
ResultMessage,
|
|
16
|
+
RunnerToControl,
|
|
17
|
+
} from "./messages.js";
|
|
18
|
+
export {
|
|
19
|
+
connectorDescriptorSchema,
|
|
20
|
+
controlToRunnerSchema,
|
|
21
|
+
parseControlToRunner,
|
|
22
|
+
parseRunnerToControl,
|
|
23
|
+
projectAdvertisementSchema,
|
|
24
|
+
PROTOCOL_VERSION,
|
|
25
|
+
runnerToControlSchema,
|
|
26
|
+
serializeMessage,
|
|
27
|
+
} from "./messages.js";
|
package/src/messages.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import { dispatchEnvelopeSchema } from "./envelope.js";
|
|
4
|
+
|
|
5
|
+
/** Bumped on any breaking change to the wire messages. */
|
|
6
|
+
export const PROTOCOL_VERSION = 3;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What a runner advertises about one connector at enrollment, so the
|
|
10
|
+
* control plane (and the agent) know what's callable and how. `params` is
|
|
11
|
+
* a JSON Schema object describing the connector's arguments.
|
|
12
|
+
*/
|
|
13
|
+
export const connectorDescriptorSchema = z.object({
|
|
14
|
+
name: z.string().min(1),
|
|
15
|
+
description: z.string(),
|
|
16
|
+
effect: z.string(),
|
|
17
|
+
/** Whether this is a built-in connector or a customer-authored action. */
|
|
18
|
+
kind: z.enum(["connector", "action"]).default("connector"),
|
|
19
|
+
params: z.record(z.string(), z.unknown()),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export type ConnectorDescriptor = z.infer<typeof connectorDescriptorSchema>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What a runner advertises about one project it serves. A project is one
|
|
26
|
+
* product/system of the runner's organization; each carries its own
|
|
27
|
+
* environments, its own capability catalog (built-in connectors whose
|
|
28
|
+
* credentials are configured for it, plus its customer-authored actions),
|
|
29
|
+
* and optionally an agent-context document (e.g. the product's database
|
|
30
|
+
* schema) the control plane injects into the agent's system prompt when the
|
|
31
|
+
* project is in scope.
|
|
32
|
+
*/
|
|
33
|
+
export const projectAdvertisementSchema = z.object({
|
|
34
|
+
/** The project's slug, unique within the runner's organization. */
|
|
35
|
+
slug: z.string().min(1),
|
|
36
|
+
/** The environments this project can target (e.g. `["dev","prod"]`). */
|
|
37
|
+
environments: z.array(z.string().min(1)).min(1),
|
|
38
|
+
/** The capabilities callable on this project. */
|
|
39
|
+
connectors: z.array(connectorDescriptorSchema),
|
|
40
|
+
/** Optional agent grounding for this project (markdown/plain text). */
|
|
41
|
+
context: z.string().optional(),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export type ProjectAdvertisement = z.infer<typeof projectAdvertisementSchema>;
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Runner → Control
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/** First message after the socket opens: the runner enrolls. */
|
|
51
|
+
const joinSchema = z.object({
|
|
52
|
+
type: z.literal("join"),
|
|
53
|
+
protocolVersion: z.number().int(),
|
|
54
|
+
/** The runner's uuid, assigned at enrollment. */
|
|
55
|
+
runnerId: z.string().min(1),
|
|
56
|
+
joinToken: z.string().min(1),
|
|
57
|
+
/** The projects this runner serves, so the control plane can route. */
|
|
58
|
+
projects: z.array(projectAdvertisementSchema).min(1),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/** The result of a dispatched envelope, keyed by the envelope's id. */
|
|
62
|
+
const resultSchema = z.object({
|
|
63
|
+
type: z.literal("result"),
|
|
64
|
+
id: z.string().min(1),
|
|
65
|
+
ok: z.boolean(),
|
|
66
|
+
result: z.unknown().optional(),
|
|
67
|
+
error: z.object({ message: z.string() }).optional(),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Application-level heartbeat. The WebSocket protocol's own ping/pong is
|
|
72
|
+
* answered by an intermediary proxy (e.g. Cloud Run's frontend) even after the
|
|
73
|
+
* backend instance is gone, so it can falsely report a dead link as healthy.
|
|
74
|
+
* This `ping` rides the same message channel as dispatches, so a missing
|
|
75
|
+
* {@link pongSchema} reply means the *application* path is dead, the only
|
|
76
|
+
* signal that reliably catches a proxy-masked half-open connection.
|
|
77
|
+
*/
|
|
78
|
+
const pingSchema = z.object({
|
|
79
|
+
type: z.literal("ping"),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export const runnerToControlSchema = z.discriminatedUnion("type", [
|
|
83
|
+
joinSchema,
|
|
84
|
+
resultSchema,
|
|
85
|
+
pingSchema,
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
export type RunnerToControl = z.infer<typeof runnerToControlSchema>;
|
|
89
|
+
export type JoinMessage = z.infer<typeof joinSchema>;
|
|
90
|
+
export type ResultMessage = z.infer<typeof resultSchema>;
|
|
91
|
+
export type PingMessage = z.infer<typeof pingSchema>;
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Control → Runner
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/** Enrollment accepted. */
|
|
98
|
+
const joinedSchema = z.object({
|
|
99
|
+
type: z.literal("joined"),
|
|
100
|
+
sessionId: z.string().min(1),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/** Enrollment refused (bad token, unknown runner, version mismatch). */
|
|
104
|
+
const rejectedSchema = z.object({
|
|
105
|
+
type: z.literal("rejected"),
|
|
106
|
+
reason: z.string(),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/** A signed unit of work for the runner to execute. */
|
|
110
|
+
const dispatchSchema = z.object({
|
|
111
|
+
type: z.literal("dispatch"),
|
|
112
|
+
envelope: dispatchEnvelopeSchema,
|
|
113
|
+
sig: z.string().min(1),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/** The control plane's reply to a runner {@link pingSchema} heartbeat. */
|
|
117
|
+
const pongSchema = z.object({
|
|
118
|
+
type: z.literal("pong"),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
export const controlToRunnerSchema = z.discriminatedUnion("type", [
|
|
122
|
+
joinedSchema,
|
|
123
|
+
rejectedSchema,
|
|
124
|
+
dispatchSchema,
|
|
125
|
+
pongSchema,
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
export type ControlToRunner = z.infer<typeof controlToRunnerSchema>;
|
|
129
|
+
export type JoinedMessage = z.infer<typeof joinedSchema>;
|
|
130
|
+
export type RejectedMessage = z.infer<typeof rejectedSchema>;
|
|
131
|
+
export type DispatchMessage = z.infer<typeof dispatchSchema>;
|
|
132
|
+
export type PongMessage = z.infer<typeof pongSchema>;
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// (de)serialization
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
export function serializeMessage(
|
|
139
|
+
message: RunnerToControl | ControlToRunner,
|
|
140
|
+
): string {
|
|
141
|
+
return JSON.stringify(message);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function parseRunnerToControl(raw: string): RunnerToControl {
|
|
145
|
+
return runnerToControlSchema.parse(JSON.parse(raw));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function parseControlToRunner(raw: string): ControlToRunner {
|
|
149
|
+
return controlToRunnerSchema.parse(JSON.parse(raw));
|
|
150
|
+
}
|