@linkshell/wire 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +11 -2
- package/dist/crypto.d.ts +65 -0
- package/dist/crypto.js +253 -0
- package/dist/crypto.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/model.d.ts +704 -0
- package/dist/model.js +163 -0
- package/dist/model.js.map +1 -0
- package/dist/peer.d.ts +42 -0
- package/dist/peer.js +143 -0
- package/dist/peer.js.map +1 -0
- package/dist/relay-client.d.ts +82 -0
- package/dist/relay-client.js +259 -0
- package/dist/relay-client.js.map +1 -0
- package/dist/relay.d.ts +481 -0
- package/dist/relay.js +129 -0
- package/dist/relay.js.map +1 -0
- package/dist/rpc.d.ts +4170 -0
- package/dist/rpc.js +332 -0
- package/dist/rpc.js.map +1 -0
- package/dist/shell.d.ts +2 -0
- package/dist/shell.js +62 -0
- package/dist/shell.js.map +1 -0
- package/dist/updates.d.ts +3325 -0
- package/dist/updates.js +210 -0
- package/dist/updates.js.map +1 -0
- package/package.json +46 -3
- package/src/crypto.ts +290 -0
- package/src/index.ts +8 -0
- package/src/model.ts +195 -0
- package/src/peer.ts +170 -0
- package/src/relay-client.ts +289 -0
- package/src/relay.ts +206 -0
- package/src/rpc.ts +364 -0
- package/src/shell.ts +53 -0
- package/src/updates.ts +233 -0
package/src/relay.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
channelKeys,
|
|
4
|
+
ephemeralKey,
|
|
5
|
+
SecureChannel,
|
|
6
|
+
sign,
|
|
7
|
+
verifySignature,
|
|
8
|
+
type Identity,
|
|
9
|
+
type PublicIdentity,
|
|
10
|
+
} from "./crypto.js";
|
|
11
|
+
|
|
12
|
+
// Gateway v2 frames. The gateway authenticates peers by their keys, routes
|
|
13
|
+
// opaque frames between a device and a machine it is allowed to reach, and
|
|
14
|
+
// brokers pairing. Everything inside a routed frame is end-to-end encrypted:
|
|
15
|
+
// the gateway never sees RPC, session content or terminal output.
|
|
16
|
+
|
|
17
|
+
export const RELAY_PATH = "/v2/connect";
|
|
18
|
+
|
|
19
|
+
export const publicIdentitySchema = z.object({ id: z.string(), signKey: z.string(), boxKey: z.string() });
|
|
20
|
+
|
|
21
|
+
export const peerRoleSchema = z.enum(["machine", "device"]);
|
|
22
|
+
export type PeerRole = z.infer<typeof peerRoleSchema>;
|
|
23
|
+
|
|
24
|
+
/** A machine as a device sees it in the gateway's list. */
|
|
25
|
+
export const machineEntrySchema = publicIdentitySchema.extend({
|
|
26
|
+
name: z.string(),
|
|
27
|
+
platform: z.string().optional(),
|
|
28
|
+
online: z.boolean(),
|
|
29
|
+
/** How this device may reach it: it paired, or both belong to the same account. */
|
|
30
|
+
via: z.enum(["paired", "account"]),
|
|
31
|
+
});
|
|
32
|
+
export type MachineEntry = z.infer<typeof machineEntrySchema>;
|
|
33
|
+
|
|
34
|
+
export const deviceEntrySchema = publicIdentitySchema.extend({
|
|
35
|
+
name: z.string(),
|
|
36
|
+
online: z.boolean(),
|
|
37
|
+
pairedAt: z.number().optional(),
|
|
38
|
+
});
|
|
39
|
+
export type DeviceEntry = z.infer<typeof deviceEntrySchema>;
|
|
40
|
+
|
|
41
|
+
// ── peer → gateway ───────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export const clientFrameSchema = z.discriminatedUnion("t", [
|
|
44
|
+
/** Answer to the challenge: a signature over it proves the key. */
|
|
45
|
+
z.object({
|
|
46
|
+
t: z.literal("auth"),
|
|
47
|
+
role: peerRoleSchema,
|
|
48
|
+
identity: publicIdentitySchema,
|
|
49
|
+
signature: z.string(),
|
|
50
|
+
name: z.string().max(120),
|
|
51
|
+
platform: z.string().max(40).optional(),
|
|
52
|
+
/** Account access token, when signed in. */
|
|
53
|
+
token: z.string().optional(),
|
|
54
|
+
}),
|
|
55
|
+
z.object({ t: z.literal("req"), id: z.number().int(), method: z.string(), params: z.unknown().optional() }),
|
|
56
|
+
/** An encrypted frame for a peer. */
|
|
57
|
+
z.object({ t: z.literal("to"), to: z.string(), d: z.string().max(40 * 1024 * 1024) }),
|
|
58
|
+
z.object({ t: z.literal("ping") }),
|
|
59
|
+
]);
|
|
60
|
+
export type ClientFrame = z.infer<typeof clientFrameSchema>;
|
|
61
|
+
|
|
62
|
+
// ── gateway → peer ───────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
export const serverFrameSchema = z.discriminatedUnion("t", [
|
|
65
|
+
z.object({ t: z.literal("challenge"), nonce: z.string() }),
|
|
66
|
+
z.object({ t: z.literal("ready"), userId: z.string().optional(), email: z.string().optional() }),
|
|
67
|
+
z.object({
|
|
68
|
+
t: z.literal("res"),
|
|
69
|
+
id: z.number().int(),
|
|
70
|
+
result: z.unknown().optional(),
|
|
71
|
+
error: z.object({ code: z.string(), message: z.string() }).optional(),
|
|
72
|
+
}),
|
|
73
|
+
z.object({ t: z.literal("event"), name: z.string(), data: z.unknown() }),
|
|
74
|
+
/** An encrypted frame from a peer, with the keys the gateway verified it holds. */
|
|
75
|
+
z.object({ t: z.literal("from"), from: publicIdentitySchema, via: z.enum(["paired", "account"]), d: z.string() }),
|
|
76
|
+
/** A frame couldn't be delivered: the peer is offline or not reachable. */
|
|
77
|
+
z.object({ t: z.literal("undeliverable"), to: z.string(), reason: z.string() }),
|
|
78
|
+
z.object({ t: z.literal("pong") }),
|
|
79
|
+
z.object({ t: z.literal("error"), code: z.string(), message: z.string() }),
|
|
80
|
+
]);
|
|
81
|
+
export type ServerFrame = z.infer<typeof serverFrameSchema>;
|
|
82
|
+
|
|
83
|
+
/** Control methods and their results. */
|
|
84
|
+
export interface RelayMethods {
|
|
85
|
+
/** Machine: open a pairing window. The QR carries `secret`; the code is for typing. */
|
|
86
|
+
"pair.offer": { params: { secretHash?: string }; result: { code: string; expiresAt: number } };
|
|
87
|
+
/** Machine: accept or refuse a claim it received as a `pair.request` event. */
|
|
88
|
+
"pair.decide": { params: { requestId: string; accept: boolean }; result: Record<string, never> };
|
|
89
|
+
/** Device: claim a pairing by code (typed) or by machine id (scanned). */
|
|
90
|
+
"pair.claim": {
|
|
91
|
+
params: { code?: string; machineId?: string; proof: string };
|
|
92
|
+
result: { machine: MachineEntry };
|
|
93
|
+
};
|
|
94
|
+
"machines.list": { params: Record<string, never>; result: { machines: MachineEntry[] } };
|
|
95
|
+
"machines.forget": { params: { machineId: string }; result: Record<string, never> };
|
|
96
|
+
"devices.list": { params: Record<string, never>; result: { devices: DeviceEntry[] } };
|
|
97
|
+
"devices.revoke": { params: { deviceId: string }; result: Record<string, never> };
|
|
98
|
+
}
|
|
99
|
+
export type RelayMethod = keyof RelayMethods;
|
|
100
|
+
|
|
101
|
+
/** Events the gateway pushes. */
|
|
102
|
+
export interface RelayEvents {
|
|
103
|
+
/** To a machine: a device wants to pair. */
|
|
104
|
+
"pair.request": { requestId: string; device: PublicIdentity & { name: string }; proof: string; code: string };
|
|
105
|
+
/** To a machine: pairing finished (accepted or not). */
|
|
106
|
+
"pair.done": { requestId: string; accepted: boolean; device?: DeviceEntry };
|
|
107
|
+
/** A reachable peer came online or went offline. */
|
|
108
|
+
presence: { id: string; online: boolean };
|
|
109
|
+
/** To a device: the list of reachable machines changed (paired, revoked, account). */
|
|
110
|
+
"machines.changed": Record<string, never>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* What `linkshell pair` puts in the QR code, kept short so the code stays
|
|
115
|
+
* scannable: the gateway, the machine's signing key (its id derives from it,
|
|
116
|
+
* and every channel is checked against it), a one-time secret and the code.
|
|
117
|
+
* The machine's name and box key come from the gateway; a wrong box key can't
|
|
118
|
+
* break a channel's secrecy because the ephemeral keys are signed.
|
|
119
|
+
*/
|
|
120
|
+
export interface PairingLink {
|
|
121
|
+
gateway: string;
|
|
122
|
+
signKey: string;
|
|
123
|
+
secret: string;
|
|
124
|
+
code: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const PAIRING_LINK_PREFIX = "linkshell://pair?";
|
|
128
|
+
|
|
129
|
+
export function encodePairingLink(link: PairingLink): string {
|
|
130
|
+
const params = new URLSearchParams({ g: link.gateway, k: link.signKey, s: link.secret, c: link.code });
|
|
131
|
+
return PAIRING_LINK_PREFIX + params.toString();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function decodePairingLink(text: string): PairingLink | undefined {
|
|
135
|
+
const trimmed = text.trim();
|
|
136
|
+
if (!trimmed.startsWith(PAIRING_LINK_PREFIX)) return undefined;
|
|
137
|
+
const params = new URLSearchParams(trimmed.slice(PAIRING_LINK_PREFIX.length));
|
|
138
|
+
const gateway = params.get("g");
|
|
139
|
+
const signKey = params.get("k");
|
|
140
|
+
const secret = params.get("s");
|
|
141
|
+
const code = params.get("c");
|
|
142
|
+
if (!gateway || !signKey || !secret || !code || !/^(wss?|https?):\/\//.test(gateway)) return undefined;
|
|
143
|
+
return { gateway, signKey, secret, code };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── end-to-end channel over the relay ────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Frames inside `to` / `from`. A device opens channel `ch` with `hello`, the
|
|
150
|
+
* machine answers `welcome`; both sign their ephemeral key with their
|
|
151
|
+
* long-term key, so neither can be impersonated by the gateway. After that
|
|
152
|
+
* every frame is `data`, sealed with the channel keys.
|
|
153
|
+
*/
|
|
154
|
+
export const tunnelFrameSchema = z.discriminatedUnion("k", [
|
|
155
|
+
z.object({ k: z.literal("hello"), ch: z.string(), eph: z.string(), sig: z.string() }),
|
|
156
|
+
z.object({ k: z.literal("welcome"), ch: z.string(), eph: z.string(), sig: z.string() }),
|
|
157
|
+
z.object({ k: z.literal("data"), ch: z.string(), box: z.string() }),
|
|
158
|
+
z.object({ k: z.literal("close"), ch: z.string(), reason: z.string().optional() }),
|
|
159
|
+
z.object({ k: z.literal("refuse"), ch: z.string(), code: z.string(), message: z.string() }),
|
|
160
|
+
]);
|
|
161
|
+
export type TunnelFrame = z.infer<typeof tunnelFrameSchema>;
|
|
162
|
+
|
|
163
|
+
function helloMessage(kind: "hello" | "welcome", channel: string, from: string, to: string, eph: string): string {
|
|
164
|
+
return `linkshell-tunnel-v2:${kind}:${channel}:${from}:${to}:${eph}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Device side: start a channel to `machine`. */
|
|
168
|
+
export function openTunnel(device: Identity, machine: PublicIdentity, channel: string) {
|
|
169
|
+
const eph = ephemeralKey();
|
|
170
|
+
const hello: TunnelFrame = {
|
|
171
|
+
k: "hello",
|
|
172
|
+
ch: channel,
|
|
173
|
+
eph: eph.public,
|
|
174
|
+
sig: sign(device, helloMessage("hello", channel, device.id, machine.id, eph.public)),
|
|
175
|
+
};
|
|
176
|
+
return {
|
|
177
|
+
hello,
|
|
178
|
+
/** Checks the machine's answer and returns the channel, or throws. */
|
|
179
|
+
accept(welcome: Extract<TunnelFrame, { k: "welcome" }>): SecureChannel {
|
|
180
|
+
if (!verifySignature(machine.signKey, helloMessage("welcome", channel, machine.id, device.id, welcome.eph), welcome.sig)) {
|
|
181
|
+
throw new Error("the computer's key doesn't match: refusing to connect");
|
|
182
|
+
}
|
|
183
|
+
return new SecureChannel(
|
|
184
|
+
channelKeys({ initiator: true, local: device, localEphemeral: eph.secret, remoteBoxKey: machine.boxKey, remoteEphemeral: welcome.eph }),
|
|
185
|
+
);
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Machine side: answer a device's hello. Throws when the signature is wrong. */
|
|
191
|
+
export function answerTunnel(machine: Identity, device: PublicIdentity, hello: Extract<TunnelFrame, { k: "hello" }>) {
|
|
192
|
+
if (!verifySignature(device.signKey, helloMessage("hello", hello.ch, device.id, machine.id, hello.eph), hello.sig)) {
|
|
193
|
+
throw new Error("bad hello signature");
|
|
194
|
+
}
|
|
195
|
+
const eph = ephemeralKey();
|
|
196
|
+
const welcome: TunnelFrame = {
|
|
197
|
+
k: "welcome",
|
|
198
|
+
ch: hello.ch,
|
|
199
|
+
eph: eph.public,
|
|
200
|
+
sig: sign(machine, helloMessage("welcome", hello.ch, machine.id, device.id, eph.public)),
|
|
201
|
+
};
|
|
202
|
+
const channel = new SecureChannel(
|
|
203
|
+
channelKeys({ initiator: false, local: machine, localEphemeral: eph.secret, remoteBoxKey: device.boxKey, remoteEphemeral: hello.eph }),
|
|
204
|
+
);
|
|
205
|
+
return { welcome, channel };
|
|
206
|
+
}
|
package/src/rpc.ts
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
gatewayStatusSchema,
|
|
4
|
+
machineInfoSchema,
|
|
5
|
+
portInfoSchema,
|
|
6
|
+
projectSummarySchema,
|
|
7
|
+
terminalInfoSchema,
|
|
8
|
+
sessionDriverSchema,
|
|
9
|
+
sessionSummarySchema,
|
|
10
|
+
} from "./model.js";
|
|
11
|
+
import { contentBlockSchema, sessionEventSchema } from "./updates.js";
|
|
12
|
+
|
|
13
|
+
// ── JSON-RPC 2.0 framing ─────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export const rpcIdSchema = z.union([z.string(), z.number()]);
|
|
16
|
+
export type RpcId = z.infer<typeof rpcIdSchema>;
|
|
17
|
+
|
|
18
|
+
export const rpcErrorSchema = z.object({
|
|
19
|
+
code: z.number().int(),
|
|
20
|
+
message: z.string(),
|
|
21
|
+
data: z.unknown().optional(),
|
|
22
|
+
});
|
|
23
|
+
export type RpcErrorObject = z.infer<typeof rpcErrorSchema>;
|
|
24
|
+
|
|
25
|
+
export const rpcMessageSchema = z.object({
|
|
26
|
+
jsonrpc: z.literal("2.0"),
|
|
27
|
+
id: rpcIdSchema.optional(),
|
|
28
|
+
method: z.string().optional(),
|
|
29
|
+
params: z.unknown().optional(),
|
|
30
|
+
result: z.unknown().optional(),
|
|
31
|
+
error: rpcErrorSchema.optional(),
|
|
32
|
+
});
|
|
33
|
+
export type RpcMessage = z.infer<typeof rpcMessageSchema>;
|
|
34
|
+
|
|
35
|
+
/** Application error codes carried in `error.data.code`. */
|
|
36
|
+
export const appErrorCodeSchema = z.enum([
|
|
37
|
+
"invalid_params",
|
|
38
|
+
"not_found",
|
|
39
|
+
"agent_unavailable",
|
|
40
|
+
"not_supported",
|
|
41
|
+
/** The session exists but can't be joined yet (e.g. a new Codex thread before its first turn). */
|
|
42
|
+
"not_ready",
|
|
43
|
+
"busy",
|
|
44
|
+
/** Client side: the link to the computer is down. */
|
|
45
|
+
"offline",
|
|
46
|
+
"internal",
|
|
47
|
+
]);
|
|
48
|
+
export type AppErrorCode = z.infer<typeof appErrorCodeSchema>;
|
|
49
|
+
|
|
50
|
+
export const RPC_INVALID_PARAMS = -32602;
|
|
51
|
+
export const RPC_METHOD_NOT_FOUND = -32601;
|
|
52
|
+
export const RPC_APP_ERROR = -32000;
|
|
53
|
+
|
|
54
|
+
// ── Methods (client → host) ──────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
const empty = z.object({}).passthrough();
|
|
57
|
+
|
|
58
|
+
export const directoryEntrySchema = z.object({
|
|
59
|
+
name: z.string(),
|
|
60
|
+
path: z.string(),
|
|
61
|
+
/** Looks like a code project (has .git, package.json, …). */
|
|
62
|
+
project: z.boolean().optional(),
|
|
63
|
+
});
|
|
64
|
+
export type DirectoryEntry = z.infer<typeof directoryEntrySchema>;
|
|
65
|
+
|
|
66
|
+
export const methods = {
|
|
67
|
+
"machine.info": {
|
|
68
|
+
params: empty,
|
|
69
|
+
result: machineInfoSchema,
|
|
70
|
+
},
|
|
71
|
+
"projects.list": {
|
|
72
|
+
params: z.object({ limit: z.number().int().positive().max(200).optional() }),
|
|
73
|
+
result: z.object({ projects: z.array(projectSummarySchema) }),
|
|
74
|
+
},
|
|
75
|
+
"sessions.list": {
|
|
76
|
+
params: z.object({
|
|
77
|
+
limit: z.number().int().positive().max(200).optional(),
|
|
78
|
+
/** Only sessions updated strictly before this timestamp (pagination). */
|
|
79
|
+
before: z.number().optional(),
|
|
80
|
+
includeArchived: z.boolean().optional(),
|
|
81
|
+
}),
|
|
82
|
+
result: z.object({
|
|
83
|
+
sessions: z.array(sessionSummarySchema),
|
|
84
|
+
nextBefore: z.number().optional(),
|
|
85
|
+
}),
|
|
86
|
+
},
|
|
87
|
+
"sessions.create": {
|
|
88
|
+
params: z.object({
|
|
89
|
+
agent: z.string().min(1),
|
|
90
|
+
cwd: z.string().min(1),
|
|
91
|
+
prompt: z.array(contentBlockSchema).optional(),
|
|
92
|
+
clientMessageId: z.string().optional(),
|
|
93
|
+
model: z.string().optional(),
|
|
94
|
+
}),
|
|
95
|
+
result: z.object({ session: sessionSummarySchema }),
|
|
96
|
+
},
|
|
97
|
+
"sessions.subscribe": {
|
|
98
|
+
params: z.object({
|
|
99
|
+
sessionId: z.string().min(1),
|
|
100
|
+
/** Last seq the client already has; 0 for a fresh subscription. */
|
|
101
|
+
fromSeq: z.number().int().nonnegative(),
|
|
102
|
+
}),
|
|
103
|
+
result: z.object({ session: sessionSummarySchema }),
|
|
104
|
+
},
|
|
105
|
+
"sessions.unsubscribe": {
|
|
106
|
+
params: z.object({ sessionId: z.string().min(1) }),
|
|
107
|
+
result: empty,
|
|
108
|
+
},
|
|
109
|
+
"sessions.prompt": {
|
|
110
|
+
params: z.object({
|
|
111
|
+
sessionId: z.string().min(1),
|
|
112
|
+
/** Client-generated idempotency key; a retried send is not delivered twice. */
|
|
113
|
+
clientMessageId: z.string().min(1),
|
|
114
|
+
content: z.array(contentBlockSchema).min(1),
|
|
115
|
+
}),
|
|
116
|
+
/** queued: the agent can't take input mid-turn; it is sent when the turn ends. */
|
|
117
|
+
result: z.object({ delivery: z.enum(["started", "steered", "queued", "duplicate"]) }),
|
|
118
|
+
},
|
|
119
|
+
"sessions.cancel": {
|
|
120
|
+
params: z.object({ sessionId: z.string().min(1) }),
|
|
121
|
+
result: empty,
|
|
122
|
+
},
|
|
123
|
+
"sessions.permission": {
|
|
124
|
+
params: z.object({
|
|
125
|
+
sessionId: z.string().min(1),
|
|
126
|
+
requestId: z.string().min(1),
|
|
127
|
+
optionId: z.string().min(1),
|
|
128
|
+
}),
|
|
129
|
+
result: empty,
|
|
130
|
+
},
|
|
131
|
+
"sessions.setConfig": {
|
|
132
|
+
params: z.object({
|
|
133
|
+
sessionId: z.string().min(1),
|
|
134
|
+
optionId: z.string().min(1),
|
|
135
|
+
value: z.string().min(1),
|
|
136
|
+
}),
|
|
137
|
+
result: empty,
|
|
138
|
+
},
|
|
139
|
+
"sessions.takeover": {
|
|
140
|
+
params: z.object({ sessionId: z.string().min(1) }),
|
|
141
|
+
result: z.object({ driver: sessionDriverSchema.optional() }),
|
|
142
|
+
},
|
|
143
|
+
"sessions.release": {
|
|
144
|
+
params: z.object({ sessionId: z.string().min(1) }),
|
|
145
|
+
result: z.object({ driver: sessionDriverSchema.optional() }),
|
|
146
|
+
},
|
|
147
|
+
/**
|
|
148
|
+
* How a desktop terminal should launch the agent's native UI attached to the
|
|
149
|
+
* host (used by `linkshell <agent>`). The shim execs exactly what it gets.
|
|
150
|
+
* For handoff agents the calling connection becomes the session's desktop
|
|
151
|
+
* controller: it receives `desktop.yield` when a device takes over.
|
|
152
|
+
*/
|
|
153
|
+
"desktop.launch": {
|
|
154
|
+
params: z.object({
|
|
155
|
+
agent: z.string().min(1),
|
|
156
|
+
sessionId: z.string().optional(),
|
|
157
|
+
args: z.array(z.string()).default([]),
|
|
158
|
+
/** The terminal's working directory and environment. */
|
|
159
|
+
cwd: z.string().optional(),
|
|
160
|
+
env: z.record(z.string()).optional(),
|
|
161
|
+
}),
|
|
162
|
+
result: z.object({
|
|
163
|
+
command: z.string(),
|
|
164
|
+
args: z.array(z.string()),
|
|
165
|
+
env: z.record(z.string()).optional(),
|
|
166
|
+
/** Set for handoff agents: the session this terminal now drives. */
|
|
167
|
+
sessionId: z.string().optional(),
|
|
168
|
+
}),
|
|
169
|
+
},
|
|
170
|
+
/** The shim's native UI exited after a `desktop.yield`; the device may drive now. */
|
|
171
|
+
"desktop.yielded": {
|
|
172
|
+
params: z.object({ sessionId: z.string().min(1) }),
|
|
173
|
+
result: empty,
|
|
174
|
+
},
|
|
175
|
+
/** The user is back at the keyboard: stop remote driving, relaunch the native UI. */
|
|
176
|
+
"desktop.reclaim": {
|
|
177
|
+
params: z.object({ sessionId: z.string().min(1) }),
|
|
178
|
+
result: z.object({
|
|
179
|
+
command: z.string(),
|
|
180
|
+
args: z.array(z.string()),
|
|
181
|
+
env: z.record(z.string()).optional(),
|
|
182
|
+
}),
|
|
183
|
+
},
|
|
184
|
+
// ── Gateway: reach this computer from anywhere ──
|
|
185
|
+
"gateway.status": { params: empty, result: gatewayStatusSchema },
|
|
186
|
+
/**
|
|
187
|
+
* Opens a pairing window (local only). `link` is what the QR encodes; the
|
|
188
|
+
* code can be typed instead. `pairing.done` follows when a device pairs.
|
|
189
|
+
*/
|
|
190
|
+
"pairing.start": {
|
|
191
|
+
params: empty,
|
|
192
|
+
result: z.object({ link: z.string(), code: z.string(), expiresAt: z.number(), gateway: z.string() }),
|
|
193
|
+
},
|
|
194
|
+
"devices.revoke": { params: z.object({ deviceId: z.string().min(1) }), result: empty },
|
|
195
|
+
// ── Directories: pick where a new session runs ──
|
|
196
|
+
/** Subdirectories of `path` (default: the home directory). */
|
|
197
|
+
"fs.list": {
|
|
198
|
+
params: z.object({ path: z.string().optional(), hidden: z.boolean().default(false) }),
|
|
199
|
+
result: z.object({
|
|
200
|
+
path: z.string(),
|
|
201
|
+
parent: z.string().optional(),
|
|
202
|
+
home: z.string(),
|
|
203
|
+
entries: z.array(directoryEntrySchema),
|
|
204
|
+
}),
|
|
205
|
+
},
|
|
206
|
+
/** A file's contents, for viewing: text as-is, images as base64, anything else refused. */
|
|
207
|
+
"fs.read": {
|
|
208
|
+
params: z.object({ path: z.string().min(1), maxBytes: z.number().int().min(1).max(8_000_000).default(2_000_000) }),
|
|
209
|
+
result: z.object({
|
|
210
|
+
path: z.string(),
|
|
211
|
+
size: z.number().int(),
|
|
212
|
+
modifiedAt: z.number(),
|
|
213
|
+
kind: z.enum(["text", "image", "binary"]),
|
|
214
|
+
text: z.string().optional(),
|
|
215
|
+
data: z.string().optional(),
|
|
216
|
+
mimeType: z.string().optional(),
|
|
217
|
+
truncated: z.boolean(),
|
|
218
|
+
}),
|
|
219
|
+
},
|
|
220
|
+
/**
|
|
221
|
+
* Saves a file from the device into `dir`. A name that's taken gets a
|
|
222
|
+
* numbered suffix ("photo 2.jpg") rather than overwriting.
|
|
223
|
+
*/
|
|
224
|
+
"fs.upload": {
|
|
225
|
+
params: z.object({
|
|
226
|
+
dir: z.string().min(1),
|
|
227
|
+
name: z.string().min(1).max(255),
|
|
228
|
+
/** base64; up to ~30 MB of file. */
|
|
229
|
+
data: z.string().max(40_000_000),
|
|
230
|
+
}),
|
|
231
|
+
result: z.object({ path: z.string(), size: z.number().int() }),
|
|
232
|
+
},
|
|
233
|
+
/** Creates a directory `name` inside `parent`. */
|
|
234
|
+
"fs.mkdir": {
|
|
235
|
+
params: z.object({ parent: z.string().min(1), name: z.string().min(1).max(255) }),
|
|
236
|
+
result: z.object({ path: z.string() }),
|
|
237
|
+
},
|
|
238
|
+
/** Directories under the home directory whose name contains `query`, projects first. */
|
|
239
|
+
"fs.search": {
|
|
240
|
+
params: z.object({ query: z.string().min(1).max(200), limit: z.number().int().min(1).max(200).default(60) }),
|
|
241
|
+
result: z.object({ entries: z.array(directoryEntrySchema), truncated: z.boolean() }),
|
|
242
|
+
},
|
|
243
|
+
// ── Terminals: plain shells on the host, any command, like SSH ──
|
|
244
|
+
"terminals.list": {
|
|
245
|
+
params: empty,
|
|
246
|
+
result: z.object({ terminals: z.array(terminalInfoSchema) }),
|
|
247
|
+
},
|
|
248
|
+
"terminals.create": {
|
|
249
|
+
params: z.object({
|
|
250
|
+
cwd: z.string().optional(),
|
|
251
|
+
/** Run this in the shell right away (the shell stays open after it). */
|
|
252
|
+
command: z.string().max(4000).optional(),
|
|
253
|
+
cols: z.number().int().min(10).max(1000).default(80),
|
|
254
|
+
rows: z.number().int().min(4).max(500).default(24),
|
|
255
|
+
}),
|
|
256
|
+
result: z.object({ terminal: terminalInfoSchema }),
|
|
257
|
+
},
|
|
258
|
+
/**
|
|
259
|
+
* Start receiving `terminal.output` for a terminal. With `fromSeq` the host
|
|
260
|
+
* sends only newer output when it still has it; otherwise `replay` holds the
|
|
261
|
+
* recent screen history to redraw from scratch.
|
|
262
|
+
*/
|
|
263
|
+
"terminals.attach": {
|
|
264
|
+
params: z.object({ terminalId: z.string().min(1), fromSeq: z.number().int().min(0).optional() }),
|
|
265
|
+
result: z.object({
|
|
266
|
+
terminal: terminalInfoSchema,
|
|
267
|
+
/** Output to write before any `terminal.output` that follows. */
|
|
268
|
+
replay: z.string(),
|
|
269
|
+
/** Clear the screen before writing `replay` (the host no longer has what came after `fromSeq`). */
|
|
270
|
+
reset: z.boolean(),
|
|
271
|
+
/** The last output seq `replay` covers; later chunks arrive as `terminal.output`. */
|
|
272
|
+
seq: z.number().int(),
|
|
273
|
+
}),
|
|
274
|
+
},
|
|
275
|
+
"terminals.detach": {
|
|
276
|
+
params: z.object({ terminalId: z.string().min(1) }),
|
|
277
|
+
result: empty,
|
|
278
|
+
},
|
|
279
|
+
"terminals.input": {
|
|
280
|
+
params: z.object({ terminalId: z.string().min(1), data: z.string().max(1_000_000) }),
|
|
281
|
+
result: empty,
|
|
282
|
+
},
|
|
283
|
+
"terminals.resize": {
|
|
284
|
+
params: z.object({
|
|
285
|
+
terminalId: z.string().min(1),
|
|
286
|
+
cols: z.number().int().min(10).max(1000),
|
|
287
|
+
rows: z.number().int().min(4).max(500),
|
|
288
|
+
}),
|
|
289
|
+
result: empty,
|
|
290
|
+
},
|
|
291
|
+
"terminals.close": {
|
|
292
|
+
params: z.object({ terminalId: z.string().min(1) }),
|
|
293
|
+
result: empty,
|
|
294
|
+
},
|
|
295
|
+
// ── Previews: the host's local servers, reached through this channel ──
|
|
296
|
+
/** Servers listening on the host that look like something to preview. */
|
|
297
|
+
"ports.list": {
|
|
298
|
+
params: empty,
|
|
299
|
+
result: z.object({ ports: z.array(portInfoSchema) }),
|
|
300
|
+
},
|
|
301
|
+
/**
|
|
302
|
+
* A TCP stream to `port` on the host's loopback, like `ssh -L`: the device
|
|
303
|
+
* runs a local listener and carries each connection over this channel, so a
|
|
304
|
+
* page, its WebSocket (hot reload) and its cookies all work unchanged. The
|
|
305
|
+
* host's bytes arrive as `proxy.data`; the stream ends with `proxy.closed`.
|
|
306
|
+
*/
|
|
307
|
+
"proxy.open": {
|
|
308
|
+
params: z.object({ port: z.number().int().min(1).max(65535) }),
|
|
309
|
+
result: z.object({ streamId: z.string() }),
|
|
310
|
+
},
|
|
311
|
+
/**
|
|
312
|
+
* Starts the host's screen viewer: a page plus H.264 stream on a loopback
|
|
313
|
+
* port, opened like a preview (`proxy.open`) at `/?token=…&display=…`.
|
|
314
|
+
*/
|
|
315
|
+
"screen.start": {
|
|
316
|
+
params: empty,
|
|
317
|
+
result: z.object({
|
|
318
|
+
port: z.number().int(),
|
|
319
|
+
token: z.string(),
|
|
320
|
+
displays: z.array(z.object({ index: z.number().int(), name: z.string() })),
|
|
321
|
+
}),
|
|
322
|
+
},
|
|
323
|
+
/** Bytes to the host's end of a stream (base64). */
|
|
324
|
+
"proxy.write": {
|
|
325
|
+
params: z.object({ streamId: z.string().min(1), data: z.string().max(2_000_000) }),
|
|
326
|
+
result: empty,
|
|
327
|
+
},
|
|
328
|
+
"proxy.close": {
|
|
329
|
+
params: z.object({ streamId: z.string().min(1) }),
|
|
330
|
+
result: empty,
|
|
331
|
+
},
|
|
332
|
+
} as const;
|
|
333
|
+
|
|
334
|
+
export type MethodName = keyof typeof methods;
|
|
335
|
+
export type MethodParams<M extends MethodName> = z.input<(typeof methods)[M]["params"]>;
|
|
336
|
+
export type MethodResult<M extends MethodName> = z.infer<(typeof methods)[M]["result"]>;
|
|
337
|
+
|
|
338
|
+
export function isMethodName(name: string): name is MethodName {
|
|
339
|
+
return Object.prototype.hasOwnProperty.call(methods, name);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ── Notifications (host → client) ────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
export const notifications = {
|
|
345
|
+
"session.event": sessionEventSchema,
|
|
346
|
+
"session.summary": z.object({ session: sessionSummarySchema }),
|
|
347
|
+
/** To a desktop shim: a device is taking over; exit the native UI, then call desktop.yielded. */
|
|
348
|
+
"desktop.yield": z.object({ sessionId: z.string() }),
|
|
349
|
+
/** To a desktop shim: progress from the remote driver, to show in the terminal. */
|
|
350
|
+
"desktop.remoteActivity": z.object({ sessionId: z.string(), line: z.string() }),
|
|
351
|
+
"pairing.done": z.object({ device: z.object({ id: z.string(), name: z.string() }) }),
|
|
352
|
+
"gateway.changed": gatewayStatusSchema,
|
|
353
|
+
/** Output from an attached terminal, in order; `seq` increases by one per chunk. */
|
|
354
|
+
"terminal.output": z.object({ terminalId: z.string(), seq: z.number().int(), data: z.string() }),
|
|
355
|
+
/** A terminal's list entry changed (title, size, exit), or it was closed (`closed`). */
|
|
356
|
+
"terminal.changed": z.object({ terminal: terminalInfoSchema, closed: z.boolean().optional() }),
|
|
357
|
+
/** Bytes from the host's end of a `proxy.open` stream (base64), in order. */
|
|
358
|
+
"proxy.data": z.object({ streamId: z.string(), data: z.string() }),
|
|
359
|
+
/** The host's end of a stream closed (the server hung up, or refused). */
|
|
360
|
+
"proxy.closed": z.object({ streamId: z.string(), error: z.string().optional() }),
|
|
361
|
+
} as const;
|
|
362
|
+
|
|
363
|
+
export type NotificationName = keyof typeof notifications;
|
|
364
|
+
export type NotificationParams<N extends NotificationName> = z.infer<(typeof notifications)[N]>;
|
package/src/shell.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Agents run commands through a login shell (`/bin/zsh -lc '<script>'`) and
|
|
2
|
+
// report that whole line. Both the host and clients show the script alone.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reads one POSIX shell word starting at `from`: any mix of '…', "…" and bare
|
|
6
|
+
* characters, as the shell would join them. Returns the word and where it
|
|
7
|
+
* ended, or undefined for an unterminated quote.
|
|
8
|
+
*/
|
|
9
|
+
function readWord(text: string, from: number): { word: string; end: number } | undefined {
|
|
10
|
+
let word = "";
|
|
11
|
+
let i = from;
|
|
12
|
+
while (i < text.length && !/\s/.test(text[i]!)) {
|
|
13
|
+
const ch = text[i]!;
|
|
14
|
+
if (ch === "'") {
|
|
15
|
+
const close = text.indexOf("'", i + 1);
|
|
16
|
+
if (close < 0) return undefined;
|
|
17
|
+
word += text.slice(i + 1, close);
|
|
18
|
+
i = close + 1;
|
|
19
|
+
} else if (ch === '"') {
|
|
20
|
+
i += 1;
|
|
21
|
+
for (;;) {
|
|
22
|
+
if (i >= text.length) return undefined;
|
|
23
|
+
const c = text[i]!;
|
|
24
|
+
if (c === '"') break;
|
|
25
|
+
if (c === "\\" && i + 1 < text.length && '"\\$`\n'.includes(text[i + 1]!)) {
|
|
26
|
+
if (text[i + 1] !== "\n") word += text[i + 1];
|
|
27
|
+
i += 2;
|
|
28
|
+
} else {
|
|
29
|
+
word += c;
|
|
30
|
+
i += 1;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
i += 1;
|
|
34
|
+
} else if (ch === "\\" && i + 1 < text.length) {
|
|
35
|
+
word += text[i + 1];
|
|
36
|
+
i += 2;
|
|
37
|
+
} else {
|
|
38
|
+
word += ch;
|
|
39
|
+
i += 1;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { word, end: i };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `/bin/zsh -lc 'npm test'` → `npm test`. Anything else comes back unchanged. */
|
|
46
|
+
export function unwrapShellCommand(command: string): string {
|
|
47
|
+
const text = command.trim();
|
|
48
|
+
const prefix = /^(?:\/\S*\/)?(?:ba|z|da|k)?sh\s+-l?c\s+/.exec(text);
|
|
49
|
+
if (!prefix) return command;
|
|
50
|
+
const read = readWord(text, prefix[0].length);
|
|
51
|
+
if (!read || text.slice(read.end).trim()) return command;
|
|
52
|
+
return read.word.trim() || command;
|
|
53
|
+
}
|