@frockbot/protocol 0.0.0 → 0.1.1

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/package.json CHANGED
@@ -1,14 +1,25 @@
1
1
  {
2
2
  "name": "@frockbot/protocol",
3
- "version": "0.0.0",
4
- "description": "Placeholder reserving this name for trusted publishing. Superseded by the first release.",
5
- "license": "UNLICENSED",
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "types": "./src/index.ts",
10
+ "scripts": {
11
+ "typecheck": "tsc --noEmit -p tsconfig.json"
12
+ },
13
+ "devDependencies": {
14
+ "@types/bun": "^1.4.0",
15
+ "typescript": "^7.0.2"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
6
20
  "repository": {
7
21
  "type": "git",
8
22
  "url": "git+https://github.com/timoconnellaus/frockbot.git",
9
23
  "directory": "packages/protocol"
10
- },
11
- "publishConfig": {
12
- "access": "public"
13
24
  }
14
25
  }
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ decodeExternalAuthorizationUrl,
4
+ isAgentCommand,
5
+ isAgentEvent,
6
+ isPromptRequest,
7
+ } from "./index";
8
+
9
+ describe("protocol guards", () => {
10
+ test("accepts a non-empty prompt", () => {
11
+ expect(isPromptRequest({ runId: "run-1", text: "Hello" })).toBe(true);
12
+ expect(
13
+ isAgentCommand({ type: "prompt", runId: "run-1", text: "Hello" }),
14
+ ).toBe(true);
15
+ });
16
+
17
+ test("rejects empty or malformed prompts", () => {
18
+ expect(isPromptRequest({ runId: "run-1", text: " " })).toBe(false);
19
+ expect(isAgentCommand({ type: "prompt", runId: "", text: "Hello" })).toBe(
20
+ false,
21
+ );
22
+ });
23
+
24
+ test("checks streamed event fields", () => {
25
+ expect(
26
+ isAgentEvent({ type: "text-delta", runId: "run-1", text: "Hi" }),
27
+ ).toBe(true);
28
+ expect(isAgentEvent({ type: "text-delta", runId: "run-1" })).toBe(false);
29
+ });
30
+
31
+ test("admits only bounded HTTPS authorization URLs", () => {
32
+ expect(
33
+ decodeExternalAuthorizationUrl("https://connect.example/authorize"),
34
+ ).toBe("https://connect.example/authorize");
35
+ for (const value of [
36
+ "http://connect.example/authorize",
37
+ "https://user:secret@connect.example/authorize",
38
+ "https://connect.example/authorize#token",
39
+ `https://connect.example/${"a".repeat(4_096)}`,
40
+ ]) {
41
+ expect(() => decodeExternalAuthorizationUrl(value)).toThrow(
42
+ "invalid external authorization URL",
43
+ );
44
+ }
45
+ });
46
+ });
package/src/index.ts ADDED
@@ -0,0 +1,154 @@
1
+ export interface PromptRequest {
2
+ runId: string;
3
+ text: string;
4
+ }
5
+
6
+ export type AgentCommand =
7
+ | { type: "prompt"; runId: string; text: string }
8
+ | { type: "abort"; runId: string }
9
+ | { type: "shutdown" };
10
+
11
+ export interface AgentModelSummary {
12
+ provider: string;
13
+ id: string;
14
+ }
15
+
16
+ export type AgentEvent =
17
+ | { type: "worker-ready"; model?: AgentModelSummary }
18
+ | { type: "run-started"; runId: string }
19
+ | { type: "text-delta"; runId: string; text: string }
20
+ | {
21
+ type: "tool-start";
22
+ runId: string;
23
+ toolCallId: string;
24
+ name: string;
25
+ input: unknown;
26
+ }
27
+ | {
28
+ type: "tool-end";
29
+ runId: string;
30
+ toolCallId: string;
31
+ name: string;
32
+ text: string;
33
+ isError: boolean;
34
+ }
35
+ | { type: "settled"; runId: string; reason: "completed" | "aborted" }
36
+ | { type: "error"; runId?: string; phase: "startup" | "run"; message: string }
37
+ | { type: "worker-exit"; code: number | null };
38
+
39
+ export interface PromptResponse {
40
+ accepted: boolean;
41
+ error?: string;
42
+ }
43
+
44
+ function isRecord(value: unknown): value is Record<string, unknown> {
45
+ return typeof value === "object" && value !== null;
46
+ }
47
+
48
+ export function isPromptRequest(value: unknown): value is PromptRequest {
49
+ return (
50
+ isRecord(value) &&
51
+ typeof value.runId === "string" &&
52
+ value.runId.length > 0 &&
53
+ typeof value.text === "string" &&
54
+ value.text.trim().length > 0
55
+ );
56
+ }
57
+
58
+ export function isAgentCommand(value: unknown): value is AgentCommand {
59
+ if (!isRecord(value) || typeof value.type !== "string") return false;
60
+ if (value.type === "shutdown") return true;
61
+ if (value.type === "abort")
62
+ return typeof value.runId === "string" && value.runId.length > 0;
63
+ return (
64
+ value.type === "prompt" &&
65
+ typeof value.runId === "string" &&
66
+ value.runId.length > 0 &&
67
+ typeof value.text === "string" &&
68
+ value.text.trim().length > 0
69
+ );
70
+ }
71
+
72
+ export function isAgentEvent(value: unknown): value is AgentEvent {
73
+ if (!isRecord(value) || typeof value.type !== "string") return false;
74
+ switch (value.type) {
75
+ case "worker-ready":
76
+ return value.model === undefined || isRecord(value.model);
77
+ case "worker-exit":
78
+ return value.code === null || typeof value.code === "number";
79
+ case "error":
80
+ return (
81
+ typeof value.message === "string" &&
82
+ (value.phase === "startup" || value.phase === "run")
83
+ );
84
+ case "run-started":
85
+ return typeof value.runId === "string";
86
+ case "text-delta":
87
+ return typeof value.runId === "string" && typeof value.text === "string";
88
+ case "tool-start":
89
+ return (
90
+ typeof value.runId === "string" &&
91
+ typeof value.toolCallId === "string" &&
92
+ typeof value.name === "string"
93
+ );
94
+ case "tool-end":
95
+ return (
96
+ typeof value.runId === "string" &&
97
+ typeof value.toolCallId === "string" &&
98
+ typeof value.name === "string" &&
99
+ typeof value.text === "string" &&
100
+ typeof value.isError === "boolean"
101
+ );
102
+ case "settled":
103
+ return (
104
+ typeof value.runId === "string" &&
105
+ (value.reason === "completed" || value.reason === "aborted")
106
+ );
107
+ default:
108
+ return false;
109
+ }
110
+ }
111
+
112
+ const MAX_EXTERNAL_AUTHORIZATION_URL_BYTES = 4_096;
113
+ const EXTERNAL_AUTHORIZATION_URL_UNSAFE_CHARACTER =
114
+ /[\u0000-\u0020\u007f-\u009f]|\s/u;
115
+ const HTTPS_AUTHORIZATION_PREFIX = /^https:\/\/[^/?#]/iu;
116
+ const DNS_HOST_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/iu;
117
+
118
+ function validAuthorizationHostname(hostname: string): boolean {
119
+ if (hostname.startsWith("[") && hostname.endsWith("]")) return true;
120
+ const normalized = hostname.endsWith(".") ? hostname.slice(0, -1) : hostname;
121
+ return (
122
+ normalized.length > 0 &&
123
+ normalized.length <= 253 &&
124
+ normalized.split(".").every((label) => DNS_HOST_LABEL.test(label))
125
+ );
126
+ }
127
+
128
+ export function decodeExternalAuthorizationUrl(value: unknown): string {
129
+ if (
130
+ typeof value !== "string" ||
131
+ !HTTPS_AUTHORIZATION_PREFIX.test(value) ||
132
+ EXTERNAL_AUTHORIZATION_URL_UNSAFE_CHARACTER.test(value) ||
133
+ value.includes("\\") ||
134
+ value.includes("#") ||
135
+ new TextEncoder().encode(value).byteLength >
136
+ MAX_EXTERNAL_AUTHORIZATION_URL_BYTES
137
+ ) {
138
+ throw new Error("invalid external authorization URL");
139
+ }
140
+ try {
141
+ const url = new URL(value);
142
+ if (
143
+ url.protocol !== "https:" ||
144
+ url.username ||
145
+ url.password ||
146
+ !validAuthorizationHostname(url.hostname)
147
+ ) {
148
+ throw new Error();
149
+ }
150
+ } catch {
151
+ throw new Error("invalid external authorization URL");
152
+ }
153
+ return value;
154
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "skipLibCheck": true,
9
+ "types": ["bun"]
10
+ },
11
+ "include": ["src/**/*.ts"]
12
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # @frockbot/protocol
2
-
3
- Placeholder reserving this name. See https://github.com/timoconnellaus/frockbot.