@constructive-db/machine-protocol 0.1.0 → 0.2.2
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 +94 -2
- package/esm/index.js +253 -0
- package/index.d.ts +145 -3
- package/index.js +260 -1
- package/package.json +14 -11
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @constructive-db/machine-protocol
|
|
2
2
|
|
|
3
|
-
The wire protocol shared by the machine relay (`compute/services/machine-relay`)
|
|
4
|
-
and the machine runner (`
|
|
3
|
+
The wire protocol shared by the machine relay (`compute/services/machine-relay` in constructive-db)
|
|
4
|
+
and the machine runner (`agentic/machine-runner`): the JSON frames a remote
|
|
5
5
|
session is made of — `open`, `input`, `resize`, `close` from the client side;
|
|
6
6
|
`output`, `exit`, `error` from the runner side; `attached` from the relay — and
|
|
7
7
|
the codec that encodes them and refuses malformed ones.
|
|
@@ -11,3 +11,95 @@ The relay never interprets terminal bytes: `input` flows client → runner and
|
|
|
11
11
|
session lifecycle. The package has no dependencies so the relay can carry it
|
|
12
12
|
into the functions image without dragging the runner's native pty bindings
|
|
13
13
|
along.
|
|
14
|
+
|
|
15
|
+
## Two legs, two vocabularies
|
|
16
|
+
|
|
17
|
+
The runner leg — what a runner sends and receives — is the generic subset:
|
|
18
|
+
`open` (`command`, `args`, `interactive`, `cols`, `rows`, `cwd`), `input`,
|
|
19
|
+
`resize`, `signal`, `detach`, `reattach`, `close` in; `output` (with an
|
|
20
|
+
optional `stream: 'stdout' | 'stderr'` for a process on pipes), `exit`,
|
|
21
|
+
`error`, `scrollback` out; plus enrollment and credential exchange. A runner
|
|
22
|
+
never sees anything else, and never parses `output`.
|
|
23
|
+
|
|
24
|
+
The agent vocabulary is the client leg's, spoken between a client and the
|
|
25
|
+
relay: an `open` may carry a binding (`runId`, `agentMode`, `cliSessionId`,
|
|
26
|
+
resolved by `agentBinding`), and the relay turns a bound program's stdout
|
|
27
|
+
lines into `agent_event` frames (`assertAgentEvent`). An approval decision is
|
|
28
|
+
not a frame at all on the runner side: the relay writes it to the program's
|
|
29
|
+
stdin as one JSON line (`encodeApprovalDecisionLine` /
|
|
30
|
+
`parseApprovalDecisionLine`), which `constructive-agent-cli` reads. The runner
|
|
31
|
+
carried bytes.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Education and Tutorials
|
|
36
|
+
|
|
37
|
+
1. 🚀 [Quickstart: Getting Up and Running](https://constructive.io/learn/quickstart)
|
|
38
|
+
Get started with modular databases in minutes. Install prerequisites and deploy your first module.
|
|
39
|
+
|
|
40
|
+
2. 📦 [Modular PostgreSQL Development with Database Packages](https://constructive.io/learn/modular-postgres)
|
|
41
|
+
Learn to organize PostgreSQL projects with pgpm workspaces and reusable database modules.
|
|
42
|
+
|
|
43
|
+
3. ✏️ [Authoring Database Changes](https://constructive.io/learn/authoring-database-changes)
|
|
44
|
+
Master the workflow for adding, organizing, and managing database changes with pgpm.
|
|
45
|
+
|
|
46
|
+
4. 🧪 [End-to-End PostgreSQL Testing with TypeScript](https://constructive.io/learn/e2e-postgres-testing)
|
|
47
|
+
Master end-to-end PostgreSQL testing with ephemeral databases, RLS testing, and CI/CD automation.
|
|
48
|
+
|
|
49
|
+
5. ⚡ [Supabase Testing](https://constructive.io/learn/supabase)
|
|
50
|
+
Use TypeScript-first tools to test Supabase projects with realistic RLS, policies, and auth contexts.
|
|
51
|
+
|
|
52
|
+
6. 💧 [Drizzle ORM Testing](https://constructive.io/learn/drizzle-testing)
|
|
53
|
+
Run full-stack tests with Drizzle ORM, including database setup, teardown, and RLS enforcement.
|
|
54
|
+
|
|
55
|
+
7. 🔧 [Troubleshooting](https://constructive.io/learn/troubleshooting)
|
|
56
|
+
Common issues and solutions for pgpm, PostgreSQL, and testing.
|
|
57
|
+
|
|
58
|
+
## Related Constructive Tooling
|
|
59
|
+
|
|
60
|
+
### 📦 Package Management
|
|
61
|
+
|
|
62
|
+
* [pgpm](https://github.com/constructive-io/constructive/tree/main/pgpm/pgpm): **🖥️ PostgreSQL Package Manager** for modular Postgres development. Works with database workspaces, scaffolding, migrations, seeding, and installing database packages.
|
|
63
|
+
|
|
64
|
+
### 🧪 Testing
|
|
65
|
+
|
|
66
|
+
* [pgsql-test](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-test): **📊 Isolated testing environments** with per-test transaction rollbacks—ideal for integration tests, complex migrations, and RLS simulation.
|
|
67
|
+
* [pglite-test](https://github.com/constructive-io/constructive/tree/main/postgres/pglite-test): **🪶 Drop-in pgsql-test replacement backed by PGlite** — in-process Postgres, no server required, instance-per-suite isolation.
|
|
68
|
+
* [pgsql-seed](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-seed): **🌱 PostgreSQL seeding utilities** for CSV, JSON, SQL data loading, and pgpm deployment.
|
|
69
|
+
* [supabase-test](https://github.com/constructive-io/constructive/tree/main/postgres/supabase-test): **🧪 Supabase-native test harness** preconfigured for the local Supabase stack—per-test rollbacks, JWT/role context helpers, and CI/GitHub Actions ready.
|
|
70
|
+
* [graphile-test](https://github.com/constructive-io/constructive/tree/main/graphile/graphile-test): **🔐 Authentication mocking** for Graphile-focused test helpers and emulating row-level security contexts.
|
|
71
|
+
* [pg-query-context](https://github.com/constructive-io/constructive/tree/main/postgres/pg-query-context): **🔒 Session context injection** to add session-local context (e.g., `SET LOCAL`) into queries—ideal for setting `role`, `jwt.claims`, and other session settings.
|
|
72
|
+
|
|
73
|
+
### 🧠 Parsing & AST
|
|
74
|
+
|
|
75
|
+
* [pgsql-parser](https://www.npmjs.com/package/pgsql-parser): **🔄 SQL conversion engine** that interprets and converts PostgreSQL syntax.
|
|
76
|
+
* [libpg-query-node](https://www.npmjs.com/package/libpg-query): **🌉 Node.js bindings** for `libpg_query`, converting SQL into parse trees.
|
|
77
|
+
* [pg-proto-parser](https://www.npmjs.com/package/pg-proto-parser): **📦 Protobuf parser** for parsing PostgreSQL Protocol Buffers definitions to generate TypeScript interfaces, utility functions, and JSON mappings for enums.
|
|
78
|
+
* [@pgsql/enums](https://www.npmjs.com/package/@pgsql/enums): **🏷️ TypeScript enums** for PostgreSQL AST for safe and ergonomic parsing logic.
|
|
79
|
+
* [@pgsql/types](https://www.npmjs.com/package/@pgsql/types): **📝 Type definitions** for PostgreSQL AST nodes in TypeScript.
|
|
80
|
+
* [@pgsql/utils](https://www.npmjs.com/package/@pgsql/utils): **🛠️ AST utilities** for constructing and transforming PostgreSQL syntax trees.
|
|
81
|
+
|
|
82
|
+
### 📚 Documentation & Skills
|
|
83
|
+
|
|
84
|
+
* [constructive-skills](https://github.com/constructive-io/constructive-skills): **📖 Platform documentation and AI agent skills** — feature catalog, blueprint reference, SDK guides (i18n, billing, limits, events, uploads, security, entities, search, AI), and deployment guides.
|
|
85
|
+
|
|
86
|
+
Install skills for AI coding agents:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
# All platform skills (security, blueprints, codegen, billing, etc.)
|
|
90
|
+
npx skills add constructive-io/constructive-skills
|
|
91
|
+
|
|
92
|
+
# Individual repo skills (pgpm, testing, CLI, search, etc.)
|
|
93
|
+
npx skills add https://github.com/constructive-io/constructive --skill pgpm
|
|
94
|
+
npx skills add https://github.com/constructive-io/constructive --skill constructive-testing
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Credits
|
|
98
|
+
|
|
99
|
+
**🛠 Built by the [Constructive](https://constructive.io) team — creators of modular Postgres tooling for secure, composable backends. If you like our work, contribute on [GitHub](https://github.com/constructive-io).**
|
|
100
|
+
|
|
101
|
+
## Disclaimer
|
|
102
|
+
|
|
103
|
+
AS DESCRIBED IN THE LICENSES, THE SOFTWARE IS PROVIDED "AS IS", AT YOUR OWN RISK, AND WITHOUT WARRANTIES OF ANY KIND.
|
|
104
|
+
|
|
105
|
+
No developer or entity involved in creating this software will be liable for any claims or damages whatsoever associated with your use, inability to use, or your interaction with other users of the code, including any direct, indirect, incidental, special, exemplary, punitive or consequential damages, or loss of profits, cryptocurrencies, tokens, or anything else of value.
|
package/esm/index.js
CHANGED
|
@@ -14,6 +14,19 @@
|
|
|
14
14
|
// a decision. That is what makes a reattach from a different client safe —
|
|
15
15
|
// nothing about the attach that opened the session carries over.
|
|
16
16
|
//
|
|
17
|
+
// Two vocabularies live here, and the line between them is the line between the
|
|
18
|
+
// runner and everything above it:
|
|
19
|
+
//
|
|
20
|
+
// - The *machine* frames (`open` … `exit`, plus enrollment) are the remote
|
|
21
|
+
// control. A runner speaks only these: it runs an allow-listed command in a
|
|
22
|
+
// pty or on pipes, moves bytes, reports the exit. It has no notion of what the
|
|
23
|
+
// command is.
|
|
24
|
+
// - The *agent* vocabulary (`AgentBinding` on an `open`, `AgentEvent`, the
|
|
25
|
+
// `agent_event` frame, the stdio contract below) is between a client, the
|
|
26
|
+
// relay and an agent program running *under* the runner. The runner never
|
|
27
|
+
// reads or writes any of it; to the runner an agent program is a command on
|
|
28
|
+
// pipes whose stdout happens to be JSON lines.
|
|
29
|
+
//
|
|
17
30
|
// This package is vocabulary, never policy. Everything a socket puts on the wire
|
|
18
31
|
// — its database, its machine id — is a *coordinate*: it says which row the
|
|
19
32
|
// socket is asking about, and the relay answers by asking that database's SQL.
|
|
@@ -86,6 +99,17 @@ export function parseClientAttachPath(path) {
|
|
|
86
99
|
machineId: decodeURIComponent(match[2])
|
|
87
100
|
};
|
|
88
101
|
}
|
|
102
|
+
export const AGENT_MODES = ['cli', 'embedded'];
|
|
103
|
+
/** The binding an `open` frame carries, or null for a plain session. */
|
|
104
|
+
export function agentBinding(frame) {
|
|
105
|
+
if (frame.runId === undefined || frame.agentMode === undefined)
|
|
106
|
+
return null;
|
|
107
|
+
return {
|
|
108
|
+
runId: frame.runId,
|
|
109
|
+
agentMode: frame.agentMode,
|
|
110
|
+
...(frame.cliSessionId !== undefined ? { cliSessionId: frame.cliSessionId } : {})
|
|
111
|
+
};
|
|
112
|
+
}
|
|
89
113
|
/**
|
|
90
114
|
* Signals a client may ask for. `SIGINT` and `SIGQUIT` are the terminal's own
|
|
91
115
|
* interrupt characters, so on a pty they are delivered the way a keyboard
|
|
@@ -94,6 +118,124 @@ export function parseClientAttachPath(path) {
|
|
|
94
118
|
* session. The rest are delivered to the session leader and do end it.
|
|
95
119
|
*/
|
|
96
120
|
export const SIGNAL_NAMES = ['SIGINT', 'SIGQUIT', 'SIGTERM', 'SIGHUP', 'SIGKILL'];
|
|
121
|
+
export const AGENT_EVENT_KINDS = [
|
|
122
|
+
'session',
|
|
123
|
+
'text',
|
|
124
|
+
'tool_call',
|
|
125
|
+
'tool_result',
|
|
126
|
+
'approval_requested',
|
|
127
|
+
'approval_resolved',
|
|
128
|
+
'result'
|
|
129
|
+
];
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// The agent program contract. An agent runs under the runner as an ordinary
|
|
132
|
+
// non-interactive command; this is what it speaks on its pipes, and the relay
|
|
133
|
+
// is the peer that reads and writes it — not the runner, which sees bytes.
|
|
134
|
+
//
|
|
135
|
+
// stdin — one prompt per line, and {@link ApprovalDecisionLine}s as JSON
|
|
136
|
+
// lines where the relay answers a question the program asked;
|
|
137
|
+
// stdout — {@link AgentEvent}s as JSON lines; any other line is plain output;
|
|
138
|
+
// stderr — plain output;
|
|
139
|
+
// exit — the program's own.
|
|
140
|
+
//
|
|
141
|
+
// `constructive-agent-host` (`@constructive-db/agent-host`) speaks it natively.
|
|
142
|
+
// `constructive-agent-cli` (`@constructive-db/agent-cli`) speaks it on behalf
|
|
143
|
+
// of a coding-agent CLI (`claude`, `codex`) that does not.
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
/**
|
|
146
|
+
* The program the relay runs for a `cli` binding: it wraps the CLI the opener
|
|
147
|
+
* named (`claude`, `codex`) and translates its stream to this contract. The
|
|
148
|
+
* machine's policy must allow it by this exact name.
|
|
149
|
+
*/
|
|
150
|
+
export const AGENT_CLI_COMMAND = 'constructive-agent-cli';
|
|
151
|
+
export function encodeApprovalDecisionLine(line) {
|
|
152
|
+
return `${JSON.stringify(line)}\n`;
|
|
153
|
+
}
|
|
154
|
+
/** The decision a stdin line carries, or null when the line is a prompt. */
|
|
155
|
+
export function parseApprovalDecisionLine(line) {
|
|
156
|
+
if (!line.startsWith('{'))
|
|
157
|
+
return null;
|
|
158
|
+
let parsed;
|
|
159
|
+
try {
|
|
160
|
+
parsed = JSON.parse(line);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
166
|
+
return null;
|
|
167
|
+
const value = parsed;
|
|
168
|
+
if (value.kind !== 'approval_decision')
|
|
169
|
+
return null;
|
|
170
|
+
if (typeof value.requestId !== 'string' || value.requestId.length === 0) {
|
|
171
|
+
throw new Error('machine-protocol: approval_decision line is missing requestId');
|
|
172
|
+
}
|
|
173
|
+
if (value.decision !== 'allow' && value.decision !== 'deny') {
|
|
174
|
+
throw new Error('machine-protocol: approval_decision line has invalid decision');
|
|
175
|
+
}
|
|
176
|
+
const reason = value.reason;
|
|
177
|
+
if (reason !== undefined && typeof reason !== 'string') {
|
|
178
|
+
throw new Error('machine-protocol: approval_decision line reason must be a string');
|
|
179
|
+
}
|
|
180
|
+
const decoded = {
|
|
181
|
+
kind: 'approval_decision',
|
|
182
|
+
requestId: value.requestId,
|
|
183
|
+
decision: value.decision
|
|
184
|
+
};
|
|
185
|
+
if (typeof reason === 'string')
|
|
186
|
+
decoded.reason = reason;
|
|
187
|
+
return decoded;
|
|
188
|
+
}
|
|
189
|
+
/** The longest line a {@link LineSplitter} buffers before giving up on it. */
|
|
190
|
+
export const MAX_LINE_LENGTH = 1024 * 1024;
|
|
191
|
+
/**
|
|
192
|
+
* Split a stream into the complete lines it has delivered so far. Keeps the
|
|
193
|
+
* unterminated tail for the next chunk; `flush` hands it over at the end. A
|
|
194
|
+
* tail that outgrows `maxLineLength` is dropped and reported, so a program
|
|
195
|
+
* that never writes a newline cannot grow the reader without bound.
|
|
196
|
+
*/
|
|
197
|
+
export class LineSplitter {
|
|
198
|
+
maxLineLength;
|
|
199
|
+
tail = '';
|
|
200
|
+
constructor(maxLineLength = MAX_LINE_LENGTH) {
|
|
201
|
+
this.maxLineLength = maxLineLength;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Complete lines are delivered before an oversized tail in the same chunk is
|
|
205
|
+
* reported: the failure is raised on the next call instead of losing them.
|
|
206
|
+
*/
|
|
207
|
+
overflowed = false;
|
|
208
|
+
push(chunk) {
|
|
209
|
+
this.raiseOverflow();
|
|
210
|
+
this.tail += chunk;
|
|
211
|
+
const lines = this.tail.split('\n');
|
|
212
|
+
this.tail = lines.pop() ?? '';
|
|
213
|
+
if (this.tail.length > this.maxLineLength) {
|
|
214
|
+
this.tail = '';
|
|
215
|
+
this.overflowed = true;
|
|
216
|
+
if (lines.length === 0)
|
|
217
|
+
this.raiseOverflow();
|
|
218
|
+
}
|
|
219
|
+
return lines.map(stripCarriageReturn);
|
|
220
|
+
}
|
|
221
|
+
raiseOverflow() {
|
|
222
|
+
if (!this.overflowed)
|
|
223
|
+
return;
|
|
224
|
+
this.overflowed = false;
|
|
225
|
+
throw new Error(`machine-protocol: line exceeds ${this.maxLineLength} characters without a newline`);
|
|
226
|
+
}
|
|
227
|
+
flush() {
|
|
228
|
+
this.raiseOverflow();
|
|
229
|
+
if (!this.tail)
|
|
230
|
+
return [];
|
|
231
|
+
const line = stripCarriageReturn(this.tail);
|
|
232
|
+
this.tail = '';
|
|
233
|
+
return [line];
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function stripCarriageReturn(line) {
|
|
237
|
+
return line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
238
|
+
}
|
|
97
239
|
const FRAME_TYPES = new Set([
|
|
98
240
|
'open',
|
|
99
241
|
'input',
|
|
@@ -103,6 +245,7 @@ const FRAME_TYPES = new Set([
|
|
|
103
245
|
'reattach',
|
|
104
246
|
'close',
|
|
105
247
|
'output',
|
|
248
|
+
'agent_event',
|
|
106
249
|
'exit',
|
|
107
250
|
'error',
|
|
108
251
|
'scrollback',
|
|
@@ -121,6 +264,7 @@ const SESSION_FRAME_TYPES = new Set([
|
|
|
121
264
|
'reattach',
|
|
122
265
|
'close',
|
|
123
266
|
'output',
|
|
267
|
+
'agent_event',
|
|
124
268
|
'exit',
|
|
125
269
|
'scrollback',
|
|
126
270
|
'detached',
|
|
@@ -140,6 +284,105 @@ function validDimension(value) {
|
|
|
140
284
|
value > 0 &&
|
|
141
285
|
value <= MAX_DIMENSION);
|
|
142
286
|
}
|
|
287
|
+
function isPlainObject(value) {
|
|
288
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Whether `value` looks like an {@link AgentEvent} — an object naming one of
|
|
292
|
+
* the event kinds. Shape is not checked; that is {@link assertAgentEvent}. A
|
|
293
|
+
* headless agent process speaks these on its stdout one per line, and this is
|
|
294
|
+
* how a relaying runner tells an event line from any other line it prints.
|
|
295
|
+
*/
|
|
296
|
+
export function isAgentEventLike(value) {
|
|
297
|
+
return (isPlainObject(value) &&
|
|
298
|
+
typeof value.kind === 'string' &&
|
|
299
|
+
AGENT_EVENT_KINDS.includes(value.kind));
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* `value` as an {@link AgentEvent}, or a thrown protocol error naming what is
|
|
303
|
+
* wrong with it. `where` names the carrier in the message (the frame, a
|
|
304
|
+
* process's stdout) so the fault is attributed.
|
|
305
|
+
*/
|
|
306
|
+
export function assertAgentEvent(value, where = "'agent_event' frame") {
|
|
307
|
+
if (!isPlainObject(value)) {
|
|
308
|
+
throw new Error(`machine-protocol: ${where} is missing event`);
|
|
309
|
+
}
|
|
310
|
+
const event = value;
|
|
311
|
+
const kind = event.kind;
|
|
312
|
+
if (typeof kind !== 'string' || !AGENT_EVENT_KINDS.includes(kind)) {
|
|
313
|
+
throw new Error(`machine-protocol: ${where} has unknown event kind '${String(kind)}'`);
|
|
314
|
+
}
|
|
315
|
+
const requiredString = (field) => {
|
|
316
|
+
if (typeof event[field] !== 'string') {
|
|
317
|
+
throw new Error(`machine-protocol: ${where} ${kind} event is missing ${field}`);
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
switch (kind) {
|
|
321
|
+
case 'session':
|
|
322
|
+
requiredString('cliSessionId');
|
|
323
|
+
break;
|
|
324
|
+
case 'text':
|
|
325
|
+
requiredString('text');
|
|
326
|
+
break;
|
|
327
|
+
case 'tool_call':
|
|
328
|
+
requiredString('id');
|
|
329
|
+
requiredString('name');
|
|
330
|
+
break;
|
|
331
|
+
case 'tool_result':
|
|
332
|
+
requiredString('id');
|
|
333
|
+
break;
|
|
334
|
+
case 'approval_requested':
|
|
335
|
+
requiredString('requestId');
|
|
336
|
+
requiredString('tool');
|
|
337
|
+
break;
|
|
338
|
+
case 'approval_resolved':
|
|
339
|
+
requiredString('requestId');
|
|
340
|
+
if (event.decision !== 'allow' && event.decision !== 'deny') {
|
|
341
|
+
throw new Error(`machine-protocol: ${where} approval_resolved event has invalid decision`);
|
|
342
|
+
}
|
|
343
|
+
break;
|
|
344
|
+
case 'result':
|
|
345
|
+
if (typeof event.ok !== 'boolean') {
|
|
346
|
+
throw new Error(`machine-protocol: ${where} result event is missing ok`);
|
|
347
|
+
}
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
return event;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* The agent-run binding, checked the way `machine_session`'s own constraint
|
|
354
|
+
* checks it, so a frame the tenant would refuse is refused before it is ever
|
|
355
|
+
* asked: `runId` and `agentMode` come together or not at all, `cliSessionId`
|
|
356
|
+
* names a CLI session and so needs `agentMode: 'cli'`, and a bound session is
|
|
357
|
+
* headless — it is the run's process, not a terminal — so `interactive` is a
|
|
358
|
+
* contradiction rather than an option.
|
|
359
|
+
*/
|
|
360
|
+
function validateAgentBinding(frame) {
|
|
361
|
+
const { runId, agentMode, cliSessionId } = frame;
|
|
362
|
+
if (runId !== undefined && (typeof runId !== 'string' || runId.length === 0)) {
|
|
363
|
+
throw new Error("machine-protocol: 'open' frame runId must be a non-empty string");
|
|
364
|
+
}
|
|
365
|
+
if (agentMode !== undefined && !AGENT_MODES.includes(agentMode)) {
|
|
366
|
+
throw new Error(`machine-protocol: 'open' frame agentMode must be one of ${AGENT_MODES.join(', ')}`);
|
|
367
|
+
}
|
|
368
|
+
if ((runId === undefined) !== (agentMode === undefined)) {
|
|
369
|
+
throw new Error("machine-protocol: 'open' frame needs both runId and agentMode, or neither");
|
|
370
|
+
}
|
|
371
|
+
if (cliSessionId !== undefined) {
|
|
372
|
+
if (typeof cliSessionId !== 'string' || cliSessionId.length === 0) {
|
|
373
|
+
throw new Error("machine-protocol: 'open' frame cliSessionId must be a non-empty string");
|
|
374
|
+
}
|
|
375
|
+
if (agentMode !== 'cli') {
|
|
376
|
+
throw new Error("machine-protocol: 'open' frame cliSessionId requires agentMode 'cli'");
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (runId !== undefined && frame.interactive === true) {
|
|
380
|
+
throw new Error("machine-protocol: 'open' frame bound to a run cannot be interactive");
|
|
381
|
+
}
|
|
382
|
+
if (frame.cwd !== undefined && (typeof frame.cwd !== 'string' || frame.cwd.length === 0)) {
|
|
383
|
+
throw new Error("machine-protocol: 'open' frame cwd must be a non-empty string");
|
|
384
|
+
}
|
|
385
|
+
}
|
|
143
386
|
export function encodeFrame(frame) {
|
|
144
387
|
return JSON.stringify(frame);
|
|
145
388
|
}
|
|
@@ -177,12 +420,22 @@ export function decodeFrame(raw) {
|
|
|
177
420
|
throw new Error(`machine-protocol: 'open' frame needs integer cols/rows in 1..${MAX_DIMENSION}`);
|
|
178
421
|
}
|
|
179
422
|
}
|
|
423
|
+
validateAgentBinding(frame);
|
|
180
424
|
break;
|
|
181
425
|
case 'input':
|
|
182
426
|
case 'output':
|
|
183
427
|
if (typeof frame.data !== 'string') {
|
|
184
428
|
throw new Error(`machine-protocol: '${type}' frame is missing data`);
|
|
185
429
|
}
|
|
430
|
+
if (type === 'output' &&
|
|
431
|
+
frame.stream !== undefined &&
|
|
432
|
+
frame.stream !== 'stdout' &&
|
|
433
|
+
frame.stream !== 'stderr') {
|
|
434
|
+
throw new Error("machine-protocol: 'output' frame stream must be 'stdout' or 'stderr'");
|
|
435
|
+
}
|
|
436
|
+
break;
|
|
437
|
+
case 'agent_event':
|
|
438
|
+
assertAgentEvent(frame.event);
|
|
186
439
|
break;
|
|
187
440
|
case 'resize':
|
|
188
441
|
if (!validDimension(frame.cols) || !validDimension(frame.rows)) {
|
package/index.d.ts
CHANGED
|
@@ -45,6 +45,28 @@ export declare function parseClientAttachPath(path: string): {
|
|
|
45
45
|
databaseId: string;
|
|
46
46
|
machineId: string;
|
|
47
47
|
} | null;
|
|
48
|
+
/**
|
|
49
|
+
* How a session bound to an agent run executes the agent: `cli` drives the
|
|
50
|
+
* user's own installed CLI, `embedded` runs our loop. Mirrors the
|
|
51
|
+
* `machine_session.agent_mode` check constraint.
|
|
52
|
+
*/
|
|
53
|
+
export type AgentMode = 'cli' | 'embedded';
|
|
54
|
+
export declare const AGENT_MODES: readonly AgentMode[];
|
|
55
|
+
/**
|
|
56
|
+
* The agent-run binding an `open` frame may carry, client → relay. A bound
|
|
57
|
+
* session is headless: its process is the agent's, driven by the run rather
|
|
58
|
+
* than by a keyboard, so it never gets a pty. Like every coordinate on this
|
|
59
|
+
* wire the run id is a request — the tenant's SQL decides whether the opener
|
|
60
|
+
* may bind to that run. The relay keeps the binding; what reaches the runner
|
|
61
|
+
* is a plain `open` for the agent program, with the binding folded into its
|
|
62
|
+
* arguments.
|
|
63
|
+
*/
|
|
64
|
+
export interface AgentBinding {
|
|
65
|
+
runId: string;
|
|
66
|
+
agentMode: AgentMode;
|
|
67
|
+
/** The CLI's own resumable session identifier; `cli` mode only. */
|
|
68
|
+
cliSessionId?: string;
|
|
69
|
+
}
|
|
48
70
|
/** Client → runner: start a process in a new session. */
|
|
49
71
|
export interface OpenFrame {
|
|
50
72
|
type: 'open';
|
|
@@ -54,13 +76,27 @@ export interface OpenFrame {
|
|
|
54
76
|
cols?: number;
|
|
55
77
|
rows?: number;
|
|
56
78
|
/**
|
|
57
|
-
* Ask for a terminal rather than
|
|
79
|
+
* Ask for a terminal rather than pipes. An interactive session gets a pty,
|
|
58
80
|
* so its output is opaque terminal bytes (escape sequences and all) and its
|
|
59
81
|
* geometry is part of the session — which is what makes `vim` render and
|
|
60
|
-
* reflow.
|
|
82
|
+
* reflow. The default is a command on pipes: stdin in, stdout and stderr out
|
|
83
|
+
* (each `output` frame says which), no echo, no geometry.
|
|
61
84
|
*/
|
|
62
85
|
interactive?: boolean;
|
|
86
|
+
/** Agent run this session executes locally. Requires `agentMode`. */
|
|
87
|
+
runId?: string;
|
|
88
|
+
/** How a bound session runs the agent. Requires `runId`. */
|
|
89
|
+
agentMode?: AgentMode;
|
|
90
|
+
/** The CLI's own session identifier; only with `agentMode: 'cli'`. */
|
|
91
|
+
cliSessionId?: string;
|
|
92
|
+
/**
|
|
93
|
+
* Working directory the session runs in, as a request: resolved against the
|
|
94
|
+
* runner's policy root, and refused when it would leave it.
|
|
95
|
+
*/
|
|
96
|
+
cwd?: string;
|
|
63
97
|
}
|
|
98
|
+
/** The binding an `open` frame carries, or null for a plain session. */
|
|
99
|
+
export declare function agentBinding(frame: OpenFrame): AgentBinding | null;
|
|
64
100
|
/** Client → runner: bytes for the session's stdin. */
|
|
65
101
|
export interface InputFrame {
|
|
66
102
|
type: 'input';
|
|
@@ -124,6 +160,95 @@ export interface OutputFrame {
|
|
|
124
160
|
type: 'output';
|
|
125
161
|
sessionId: string;
|
|
126
162
|
data: string;
|
|
163
|
+
/**
|
|
164
|
+
* Which pipe the bytes came from, on a non-interactive session. A terminal
|
|
165
|
+
* has one stream, so a pty session's output carries none; a program on
|
|
166
|
+
* pipes has two, and a consumer parsing stdout line by line (an agent's
|
|
167
|
+
* events) must not have stderr spliced into it.
|
|
168
|
+
*/
|
|
169
|
+
stream?: 'stdout' | 'stderr';
|
|
170
|
+
}
|
|
171
|
+
export type AgentEvent = {
|
|
172
|
+
kind: 'session';
|
|
173
|
+
cliSessionId: string;
|
|
174
|
+
} | {
|
|
175
|
+
kind: 'text';
|
|
176
|
+
text: string;
|
|
177
|
+
} | {
|
|
178
|
+
kind: 'tool_call';
|
|
179
|
+
id: string;
|
|
180
|
+
name: string;
|
|
181
|
+
input: unknown;
|
|
182
|
+
} | {
|
|
183
|
+
kind: 'tool_result';
|
|
184
|
+
id: string;
|
|
185
|
+
output: unknown;
|
|
186
|
+
isError?: boolean;
|
|
187
|
+
} | {
|
|
188
|
+
kind: 'approval_requested';
|
|
189
|
+
requestId: string;
|
|
190
|
+
tool: string;
|
|
191
|
+
input: unknown;
|
|
192
|
+
reason?: string;
|
|
193
|
+
} | {
|
|
194
|
+
kind: 'approval_resolved';
|
|
195
|
+
requestId: string;
|
|
196
|
+
decision: 'allow' | 'deny';
|
|
197
|
+
reason?: string;
|
|
198
|
+
} | {
|
|
199
|
+
kind: 'result';
|
|
200
|
+
ok: boolean;
|
|
201
|
+
summary?: string;
|
|
202
|
+
usage?: unknown;
|
|
203
|
+
costUsd?: number;
|
|
204
|
+
};
|
|
205
|
+
export declare const AGENT_EVENT_KINDS: readonly AgentEvent['kind'][];
|
|
206
|
+
export interface AgentEventFrame {
|
|
207
|
+
type: 'agent_event';
|
|
208
|
+
sessionId: string;
|
|
209
|
+
event: AgentEvent;
|
|
210
|
+
}
|
|
211
|
+
export type ApprovalDecision = 'allow' | 'deny';
|
|
212
|
+
/**
|
|
213
|
+
* The program the relay runs for a `cli` binding: it wraps the CLI the opener
|
|
214
|
+
* named (`claude`, `codex`) and translates its stream to this contract. The
|
|
215
|
+
* machine's policy must allow it by this exact name.
|
|
216
|
+
*/
|
|
217
|
+
export declare const AGENT_CLI_COMMAND = "constructive-agent-cli";
|
|
218
|
+
/**
|
|
219
|
+
* Relay → agent program (a line on its stdin): the answer to an
|
|
220
|
+
* `approval_requested` event the program emitted. Carries no identity: who
|
|
221
|
+
* answered was the run's RLS, asked on the bound run's log.
|
|
222
|
+
*/
|
|
223
|
+
export interface ApprovalDecisionLine {
|
|
224
|
+
kind: 'approval_decision';
|
|
225
|
+
requestId: string;
|
|
226
|
+
decision: ApprovalDecision;
|
|
227
|
+
reason?: string;
|
|
228
|
+
}
|
|
229
|
+
export declare function encodeApprovalDecisionLine(line: ApprovalDecisionLine): string;
|
|
230
|
+
/** The decision a stdin line carries, or null when the line is a prompt. */
|
|
231
|
+
export declare function parseApprovalDecisionLine(line: string): ApprovalDecisionLine | null;
|
|
232
|
+
/** The longest line a {@link LineSplitter} buffers before giving up on it. */
|
|
233
|
+
export declare const MAX_LINE_LENGTH: number;
|
|
234
|
+
/**
|
|
235
|
+
* Split a stream into the complete lines it has delivered so far. Keeps the
|
|
236
|
+
* unterminated tail for the next chunk; `flush` hands it over at the end. A
|
|
237
|
+
* tail that outgrows `maxLineLength` is dropped and reported, so a program
|
|
238
|
+
* that never writes a newline cannot grow the reader without bound.
|
|
239
|
+
*/
|
|
240
|
+
export declare class LineSplitter {
|
|
241
|
+
private readonly maxLineLength;
|
|
242
|
+
private tail;
|
|
243
|
+
constructor(maxLineLength?: number);
|
|
244
|
+
/**
|
|
245
|
+
* Complete lines are delivered before an oversized tail in the same chunk is
|
|
246
|
+
* reported: the failure is raised on the next call instead of losing them.
|
|
247
|
+
*/
|
|
248
|
+
private overflowed;
|
|
249
|
+
push(chunk: string): string[];
|
|
250
|
+
private raiseOverflow;
|
|
251
|
+
flush(): string[];
|
|
127
252
|
}
|
|
128
253
|
/** Runner → client: the process ended. */
|
|
129
254
|
export interface ExitFrame {
|
|
@@ -141,6 +266,8 @@ export interface ErrorFrame {
|
|
|
141
266
|
type: 'error';
|
|
142
267
|
sessionId?: string;
|
|
143
268
|
message: string;
|
|
269
|
+
/** A stable, machine-readable reason, where the sender has one. */
|
|
270
|
+
code?: string;
|
|
144
271
|
}
|
|
145
272
|
/**
|
|
146
273
|
* Runner → relay: the scrollback a detaching session leaves behind, capped by
|
|
@@ -199,8 +326,23 @@ export interface EnrolledFrame {
|
|
|
199
326
|
expiresAt: number;
|
|
200
327
|
}
|
|
201
328
|
export type ClientToRunnerFrame = OpenFrame | InputFrame | ResizeFrame | SignalFrame | DetachFrame | ReattachFrame | CloseFrame;
|
|
202
|
-
export type RunnerToClientFrame = OutputFrame | ExitFrame | ErrorFrame | ScrollbackFrame;
|
|
329
|
+
export type RunnerToClientFrame = OutputFrame | AgentEventFrame | ExitFrame | ErrorFrame | ScrollbackFrame;
|
|
203
330
|
export type Frame = ClientToRunnerFrame | RunnerToClientFrame | AttachedFrame | DetachedFrame | ReattachedFrame | EnrolledFrame;
|
|
331
|
+
/**
|
|
332
|
+
* Whether `value` looks like an {@link AgentEvent} — an object naming one of
|
|
333
|
+
* the event kinds. Shape is not checked; that is {@link assertAgentEvent}. A
|
|
334
|
+
* headless agent process speaks these on its stdout one per line, and this is
|
|
335
|
+
* how a relaying runner tells an event line from any other line it prints.
|
|
336
|
+
*/
|
|
337
|
+
export declare function isAgentEventLike(value: unknown): value is Record<string, unknown> & {
|
|
338
|
+
kind: AgentEvent['kind'];
|
|
339
|
+
};
|
|
340
|
+
/**
|
|
341
|
+
* `value` as an {@link AgentEvent}, or a thrown protocol error naming what is
|
|
342
|
+
* wrong with it. `where` names the carrier in the message (the frame, a
|
|
343
|
+
* process's stdout) so the fault is attributed.
|
|
344
|
+
*/
|
|
345
|
+
export declare function assertAgentEvent(value: unknown, where?: string): AgentEvent;
|
|
204
346
|
export declare function encodeFrame(frame: Frame): string;
|
|
205
347
|
/**
|
|
206
348
|
* Parse one wire message into a frame. Malformed input is a protocol fault on
|
package/index.js
CHANGED
|
@@ -15,6 +15,19 @@
|
|
|
15
15
|
// a decision. That is what makes a reattach from a different client safe —
|
|
16
16
|
// nothing about the attach that opened the session carries over.
|
|
17
17
|
//
|
|
18
|
+
// Two vocabularies live here, and the line between them is the line between the
|
|
19
|
+
// runner and everything above it:
|
|
20
|
+
//
|
|
21
|
+
// - The *machine* frames (`open` … `exit`, plus enrollment) are the remote
|
|
22
|
+
// control. A runner speaks only these: it runs an allow-listed command in a
|
|
23
|
+
// pty or on pipes, moves bytes, reports the exit. It has no notion of what the
|
|
24
|
+
// command is.
|
|
25
|
+
// - The *agent* vocabulary (`AgentBinding` on an `open`, `AgentEvent`, the
|
|
26
|
+
// `agent_event` frame, the stdio contract below) is between a client, the
|
|
27
|
+
// relay and an agent program running *under* the runner. The runner never
|
|
28
|
+
// reads or writes any of it; to the runner an agent program is a command on
|
|
29
|
+
// pipes whose stdout happens to be JSON lines.
|
|
30
|
+
//
|
|
18
31
|
// This package is vocabulary, never policy. Everything a socket puts on the wire
|
|
19
32
|
// — its database, its machine id — is a *coordinate*: it says which row the
|
|
20
33
|
// socket is asking about, and the relay answers by asking that database's SQL.
|
|
@@ -23,9 +36,14 @@
|
|
|
23
36
|
// the tenant that issued it, and RLS decides everything after that. Addressing a
|
|
24
37
|
// database you have no credential for is simply a refusal.
|
|
25
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
-
exports.SIGNAL_NAMES = exports.attachSubprotocols = exports.ATTACH_SUBPROTOCOL = exports.clientAttachPath = exports.RUNNER_CONNECT_PATH = exports.HEADER_MACHINE_DATABASE = exports.HEADER_MACHINE_ID = void 0;
|
|
39
|
+
exports.LineSplitter = exports.MAX_LINE_LENGTH = exports.AGENT_CLI_COMMAND = exports.AGENT_EVENT_KINDS = exports.SIGNAL_NAMES = exports.AGENT_MODES = exports.attachSubprotocols = exports.ATTACH_SUBPROTOCOL = exports.clientAttachPath = exports.RUNNER_CONNECT_PATH = exports.HEADER_MACHINE_DATABASE = exports.HEADER_MACHINE_ID = void 0;
|
|
27
40
|
exports.credentialFromSubprotocols = credentialFromSubprotocols;
|
|
28
41
|
exports.parseClientAttachPath = parseClientAttachPath;
|
|
42
|
+
exports.agentBinding = agentBinding;
|
|
43
|
+
exports.encodeApprovalDecisionLine = encodeApprovalDecisionLine;
|
|
44
|
+
exports.parseApprovalDecisionLine = parseApprovalDecisionLine;
|
|
45
|
+
exports.isAgentEventLike = isAgentEventLike;
|
|
46
|
+
exports.assertAgentEvent = assertAgentEvent;
|
|
29
47
|
exports.encodeFrame = encodeFrame;
|
|
30
48
|
exports.decodeFrame = decodeFrame;
|
|
31
49
|
// Token hashing is deliberately NOT re-exported here: it needs node's `crypto`,
|
|
@@ -95,6 +113,17 @@ function parseClientAttachPath(path) {
|
|
|
95
113
|
machineId: decodeURIComponent(match[2])
|
|
96
114
|
};
|
|
97
115
|
}
|
|
116
|
+
exports.AGENT_MODES = ['cli', 'embedded'];
|
|
117
|
+
/** The binding an `open` frame carries, or null for a plain session. */
|
|
118
|
+
function agentBinding(frame) {
|
|
119
|
+
if (frame.runId === undefined || frame.agentMode === undefined)
|
|
120
|
+
return null;
|
|
121
|
+
return {
|
|
122
|
+
runId: frame.runId,
|
|
123
|
+
agentMode: frame.agentMode,
|
|
124
|
+
...(frame.cliSessionId !== undefined ? { cliSessionId: frame.cliSessionId } : {})
|
|
125
|
+
};
|
|
126
|
+
}
|
|
98
127
|
/**
|
|
99
128
|
* Signals a client may ask for. `SIGINT` and `SIGQUIT` are the terminal's own
|
|
100
129
|
* interrupt characters, so on a pty they are delivered the way a keyboard
|
|
@@ -103,6 +132,125 @@ function parseClientAttachPath(path) {
|
|
|
103
132
|
* session. The rest are delivered to the session leader and do end it.
|
|
104
133
|
*/
|
|
105
134
|
exports.SIGNAL_NAMES = ['SIGINT', 'SIGQUIT', 'SIGTERM', 'SIGHUP', 'SIGKILL'];
|
|
135
|
+
exports.AGENT_EVENT_KINDS = [
|
|
136
|
+
'session',
|
|
137
|
+
'text',
|
|
138
|
+
'tool_call',
|
|
139
|
+
'tool_result',
|
|
140
|
+
'approval_requested',
|
|
141
|
+
'approval_resolved',
|
|
142
|
+
'result'
|
|
143
|
+
];
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// The agent program contract. An agent runs under the runner as an ordinary
|
|
146
|
+
// non-interactive command; this is what it speaks on its pipes, and the relay
|
|
147
|
+
// is the peer that reads and writes it — not the runner, which sees bytes.
|
|
148
|
+
//
|
|
149
|
+
// stdin — one prompt per line, and {@link ApprovalDecisionLine}s as JSON
|
|
150
|
+
// lines where the relay answers a question the program asked;
|
|
151
|
+
// stdout — {@link AgentEvent}s as JSON lines; any other line is plain output;
|
|
152
|
+
// stderr — plain output;
|
|
153
|
+
// exit — the program's own.
|
|
154
|
+
//
|
|
155
|
+
// `constructive-agent-host` (`@constructive-db/agent-host`) speaks it natively.
|
|
156
|
+
// `constructive-agent-cli` (`@constructive-db/agent-cli`) speaks it on behalf
|
|
157
|
+
// of a coding-agent CLI (`claude`, `codex`) that does not.
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
/**
|
|
160
|
+
* The program the relay runs for a `cli` binding: it wraps the CLI the opener
|
|
161
|
+
* named (`claude`, `codex`) and translates its stream to this contract. The
|
|
162
|
+
* machine's policy must allow it by this exact name.
|
|
163
|
+
*/
|
|
164
|
+
exports.AGENT_CLI_COMMAND = 'constructive-agent-cli';
|
|
165
|
+
function encodeApprovalDecisionLine(line) {
|
|
166
|
+
return `${JSON.stringify(line)}\n`;
|
|
167
|
+
}
|
|
168
|
+
/** The decision a stdin line carries, or null when the line is a prompt. */
|
|
169
|
+
function parseApprovalDecisionLine(line) {
|
|
170
|
+
if (!line.startsWith('{'))
|
|
171
|
+
return null;
|
|
172
|
+
let parsed;
|
|
173
|
+
try {
|
|
174
|
+
parsed = JSON.parse(line);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
180
|
+
return null;
|
|
181
|
+
const value = parsed;
|
|
182
|
+
if (value.kind !== 'approval_decision')
|
|
183
|
+
return null;
|
|
184
|
+
if (typeof value.requestId !== 'string' || value.requestId.length === 0) {
|
|
185
|
+
throw new Error('machine-protocol: approval_decision line is missing requestId');
|
|
186
|
+
}
|
|
187
|
+
if (value.decision !== 'allow' && value.decision !== 'deny') {
|
|
188
|
+
throw new Error('machine-protocol: approval_decision line has invalid decision');
|
|
189
|
+
}
|
|
190
|
+
const reason = value.reason;
|
|
191
|
+
if (reason !== undefined && typeof reason !== 'string') {
|
|
192
|
+
throw new Error('machine-protocol: approval_decision line reason must be a string');
|
|
193
|
+
}
|
|
194
|
+
const decoded = {
|
|
195
|
+
kind: 'approval_decision',
|
|
196
|
+
requestId: value.requestId,
|
|
197
|
+
decision: value.decision
|
|
198
|
+
};
|
|
199
|
+
if (typeof reason === 'string')
|
|
200
|
+
decoded.reason = reason;
|
|
201
|
+
return decoded;
|
|
202
|
+
}
|
|
203
|
+
/** The longest line a {@link LineSplitter} buffers before giving up on it. */
|
|
204
|
+
exports.MAX_LINE_LENGTH = 1024 * 1024;
|
|
205
|
+
/**
|
|
206
|
+
* Split a stream into the complete lines it has delivered so far. Keeps the
|
|
207
|
+
* unterminated tail for the next chunk; `flush` hands it over at the end. A
|
|
208
|
+
* tail that outgrows `maxLineLength` is dropped and reported, so a program
|
|
209
|
+
* that never writes a newline cannot grow the reader without bound.
|
|
210
|
+
*/
|
|
211
|
+
class LineSplitter {
|
|
212
|
+
maxLineLength;
|
|
213
|
+
tail = '';
|
|
214
|
+
constructor(maxLineLength = exports.MAX_LINE_LENGTH) {
|
|
215
|
+
this.maxLineLength = maxLineLength;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Complete lines are delivered before an oversized tail in the same chunk is
|
|
219
|
+
* reported: the failure is raised on the next call instead of losing them.
|
|
220
|
+
*/
|
|
221
|
+
overflowed = false;
|
|
222
|
+
push(chunk) {
|
|
223
|
+
this.raiseOverflow();
|
|
224
|
+
this.tail += chunk;
|
|
225
|
+
const lines = this.tail.split('\n');
|
|
226
|
+
this.tail = lines.pop() ?? '';
|
|
227
|
+
if (this.tail.length > this.maxLineLength) {
|
|
228
|
+
this.tail = '';
|
|
229
|
+
this.overflowed = true;
|
|
230
|
+
if (lines.length === 0)
|
|
231
|
+
this.raiseOverflow();
|
|
232
|
+
}
|
|
233
|
+
return lines.map(stripCarriageReturn);
|
|
234
|
+
}
|
|
235
|
+
raiseOverflow() {
|
|
236
|
+
if (!this.overflowed)
|
|
237
|
+
return;
|
|
238
|
+
this.overflowed = false;
|
|
239
|
+
throw new Error(`machine-protocol: line exceeds ${this.maxLineLength} characters without a newline`);
|
|
240
|
+
}
|
|
241
|
+
flush() {
|
|
242
|
+
this.raiseOverflow();
|
|
243
|
+
if (!this.tail)
|
|
244
|
+
return [];
|
|
245
|
+
const line = stripCarriageReturn(this.tail);
|
|
246
|
+
this.tail = '';
|
|
247
|
+
return [line];
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
exports.LineSplitter = LineSplitter;
|
|
251
|
+
function stripCarriageReturn(line) {
|
|
252
|
+
return line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
253
|
+
}
|
|
106
254
|
const FRAME_TYPES = new Set([
|
|
107
255
|
'open',
|
|
108
256
|
'input',
|
|
@@ -112,6 +260,7 @@ const FRAME_TYPES = new Set([
|
|
|
112
260
|
'reattach',
|
|
113
261
|
'close',
|
|
114
262
|
'output',
|
|
263
|
+
'agent_event',
|
|
115
264
|
'exit',
|
|
116
265
|
'error',
|
|
117
266
|
'scrollback',
|
|
@@ -130,6 +279,7 @@ const SESSION_FRAME_TYPES = new Set([
|
|
|
130
279
|
'reattach',
|
|
131
280
|
'close',
|
|
132
281
|
'output',
|
|
282
|
+
'agent_event',
|
|
133
283
|
'exit',
|
|
134
284
|
'scrollback',
|
|
135
285
|
'detached',
|
|
@@ -149,6 +299,105 @@ function validDimension(value) {
|
|
|
149
299
|
value > 0 &&
|
|
150
300
|
value <= MAX_DIMENSION);
|
|
151
301
|
}
|
|
302
|
+
function isPlainObject(value) {
|
|
303
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Whether `value` looks like an {@link AgentEvent} — an object naming one of
|
|
307
|
+
* the event kinds. Shape is not checked; that is {@link assertAgentEvent}. A
|
|
308
|
+
* headless agent process speaks these on its stdout one per line, and this is
|
|
309
|
+
* how a relaying runner tells an event line from any other line it prints.
|
|
310
|
+
*/
|
|
311
|
+
function isAgentEventLike(value) {
|
|
312
|
+
return (isPlainObject(value) &&
|
|
313
|
+
typeof value.kind === 'string' &&
|
|
314
|
+
exports.AGENT_EVENT_KINDS.includes(value.kind));
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* `value` as an {@link AgentEvent}, or a thrown protocol error naming what is
|
|
318
|
+
* wrong with it. `where` names the carrier in the message (the frame, a
|
|
319
|
+
* process's stdout) so the fault is attributed.
|
|
320
|
+
*/
|
|
321
|
+
function assertAgentEvent(value, where = "'agent_event' frame") {
|
|
322
|
+
if (!isPlainObject(value)) {
|
|
323
|
+
throw new Error(`machine-protocol: ${where} is missing event`);
|
|
324
|
+
}
|
|
325
|
+
const event = value;
|
|
326
|
+
const kind = event.kind;
|
|
327
|
+
if (typeof kind !== 'string' || !exports.AGENT_EVENT_KINDS.includes(kind)) {
|
|
328
|
+
throw new Error(`machine-protocol: ${where} has unknown event kind '${String(kind)}'`);
|
|
329
|
+
}
|
|
330
|
+
const requiredString = (field) => {
|
|
331
|
+
if (typeof event[field] !== 'string') {
|
|
332
|
+
throw new Error(`machine-protocol: ${where} ${kind} event is missing ${field}`);
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
switch (kind) {
|
|
336
|
+
case 'session':
|
|
337
|
+
requiredString('cliSessionId');
|
|
338
|
+
break;
|
|
339
|
+
case 'text':
|
|
340
|
+
requiredString('text');
|
|
341
|
+
break;
|
|
342
|
+
case 'tool_call':
|
|
343
|
+
requiredString('id');
|
|
344
|
+
requiredString('name');
|
|
345
|
+
break;
|
|
346
|
+
case 'tool_result':
|
|
347
|
+
requiredString('id');
|
|
348
|
+
break;
|
|
349
|
+
case 'approval_requested':
|
|
350
|
+
requiredString('requestId');
|
|
351
|
+
requiredString('tool');
|
|
352
|
+
break;
|
|
353
|
+
case 'approval_resolved':
|
|
354
|
+
requiredString('requestId');
|
|
355
|
+
if (event.decision !== 'allow' && event.decision !== 'deny') {
|
|
356
|
+
throw new Error(`machine-protocol: ${where} approval_resolved event has invalid decision`);
|
|
357
|
+
}
|
|
358
|
+
break;
|
|
359
|
+
case 'result':
|
|
360
|
+
if (typeof event.ok !== 'boolean') {
|
|
361
|
+
throw new Error(`machine-protocol: ${where} result event is missing ok`);
|
|
362
|
+
}
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
return event;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* The agent-run binding, checked the way `machine_session`'s own constraint
|
|
369
|
+
* checks it, so a frame the tenant would refuse is refused before it is ever
|
|
370
|
+
* asked: `runId` and `agentMode` come together or not at all, `cliSessionId`
|
|
371
|
+
* names a CLI session and so needs `agentMode: 'cli'`, and a bound session is
|
|
372
|
+
* headless — it is the run's process, not a terminal — so `interactive` is a
|
|
373
|
+
* contradiction rather than an option.
|
|
374
|
+
*/
|
|
375
|
+
function validateAgentBinding(frame) {
|
|
376
|
+
const { runId, agentMode, cliSessionId } = frame;
|
|
377
|
+
if (runId !== undefined && (typeof runId !== 'string' || runId.length === 0)) {
|
|
378
|
+
throw new Error("machine-protocol: 'open' frame runId must be a non-empty string");
|
|
379
|
+
}
|
|
380
|
+
if (agentMode !== undefined && !exports.AGENT_MODES.includes(agentMode)) {
|
|
381
|
+
throw new Error(`machine-protocol: 'open' frame agentMode must be one of ${exports.AGENT_MODES.join(', ')}`);
|
|
382
|
+
}
|
|
383
|
+
if ((runId === undefined) !== (agentMode === undefined)) {
|
|
384
|
+
throw new Error("machine-protocol: 'open' frame needs both runId and agentMode, or neither");
|
|
385
|
+
}
|
|
386
|
+
if (cliSessionId !== undefined) {
|
|
387
|
+
if (typeof cliSessionId !== 'string' || cliSessionId.length === 0) {
|
|
388
|
+
throw new Error("machine-protocol: 'open' frame cliSessionId must be a non-empty string");
|
|
389
|
+
}
|
|
390
|
+
if (agentMode !== 'cli') {
|
|
391
|
+
throw new Error("machine-protocol: 'open' frame cliSessionId requires agentMode 'cli'");
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (runId !== undefined && frame.interactive === true) {
|
|
395
|
+
throw new Error("machine-protocol: 'open' frame bound to a run cannot be interactive");
|
|
396
|
+
}
|
|
397
|
+
if (frame.cwd !== undefined && (typeof frame.cwd !== 'string' || frame.cwd.length === 0)) {
|
|
398
|
+
throw new Error("machine-protocol: 'open' frame cwd must be a non-empty string");
|
|
399
|
+
}
|
|
400
|
+
}
|
|
152
401
|
function encodeFrame(frame) {
|
|
153
402
|
return JSON.stringify(frame);
|
|
154
403
|
}
|
|
@@ -186,12 +435,22 @@ function decodeFrame(raw) {
|
|
|
186
435
|
throw new Error(`machine-protocol: 'open' frame needs integer cols/rows in 1..${MAX_DIMENSION}`);
|
|
187
436
|
}
|
|
188
437
|
}
|
|
438
|
+
validateAgentBinding(frame);
|
|
189
439
|
break;
|
|
190
440
|
case 'input':
|
|
191
441
|
case 'output':
|
|
192
442
|
if (typeof frame.data !== 'string') {
|
|
193
443
|
throw new Error(`machine-protocol: '${type}' frame is missing data`);
|
|
194
444
|
}
|
|
445
|
+
if (type === 'output' &&
|
|
446
|
+
frame.stream !== undefined &&
|
|
447
|
+
frame.stream !== 'stdout' &&
|
|
448
|
+
frame.stream !== 'stderr') {
|
|
449
|
+
throw new Error("machine-protocol: 'output' frame stream must be 'stdout' or 'stderr'");
|
|
450
|
+
}
|
|
451
|
+
break;
|
|
452
|
+
case 'agent_event':
|
|
453
|
+
assertAgentEvent(frame.event);
|
|
195
454
|
break;
|
|
196
455
|
case 'resize':
|
|
197
456
|
if (!validDimension(frame.cols) || !validDimension(frame.rows)) {
|
package/package.json
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@constructive-db/machine-protocol",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Wire protocol shared by the machine relay and the machine runner: the JSON frames a session is made of, and the codec that refuses malformed ones.",
|
|
5
5
|
"author": "Constructive <developers@constructive.io>",
|
|
6
6
|
"license": "MIT",
|
|
7
|
+
"homepage": "https://github.com/constructive-io/constructive",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/constructive-io/constructive",
|
|
11
|
+
"directory": "agentic/machine-protocol"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/constructive-io/constructive/issues"
|
|
15
|
+
},
|
|
7
16
|
"main": "index.js",
|
|
8
17
|
"module": "esm/index.js",
|
|
9
18
|
"types": "index.d.ts",
|
|
@@ -11,19 +20,13 @@
|
|
|
11
20
|
"access": "public",
|
|
12
21
|
"directory": "dist"
|
|
13
22
|
},
|
|
14
|
-
"devDependencies": {
|
|
15
|
-
"@types/jest": "^29.5.14",
|
|
16
|
-
"@types/node": "^22.10.4",
|
|
17
|
-
"jest": "^29.7.0",
|
|
18
|
-
"makage": "^0.1.12",
|
|
19
|
-
"ts-jest": "^29.2.0",
|
|
20
|
-
"typescript": "^5.1.6"
|
|
21
|
-
},
|
|
22
23
|
"scripts": {
|
|
24
|
+
"prepack": "npm run build",
|
|
23
25
|
"build": "makage build && cp LICENSE dist/LICENSE",
|
|
24
26
|
"build:dev": "makage build --dev && cp LICENSE dist/LICENSE",
|
|
25
27
|
"clean": "makage clean",
|
|
26
28
|
"lint": "eslint . --fix",
|
|
27
29
|
"test": "jest"
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
+
},
|
|
31
|
+
"gitHead": "81eed19332cc231de0fde02bc6e87002357f9536"
|
|
32
|
+
}
|