@fitchmultz/pi-subagents 0.46.2 → 0.48.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/CHANGELOG.md +25 -0
- package/README.md +1 -1
- package/dist/extension/build-info.js +1 -1
- package/dist/pi-intercom/bridge-broadcast.js +60 -0
- package/dist/pi-intercom/bridge-catalog.js +372 -0
- package/dist/pi-intercom/bridge-config.js +47 -26
- package/dist/pi-intercom/bridge-control.js +104 -0
- package/dist/pi-intercom/bridge-files.js +47 -0
- package/dist/pi-intercom/bridge-groups.js +135 -0
- package/dist/pi-intercom/bridge-http.js +8 -1
- package/dist/pi-intercom/bridge-launch-journal.js +84 -0
- package/dist/pi-intercom/bridge-launch-policy.js +71 -5
- package/dist/pi-intercom/bridge-launch.js +186 -76
- package/dist/pi-intercom/bridge-mailbox.js +324 -0
- package/dist/pi-intercom/bridge-operations.js +127 -0
- package/dist/pi-intercom/bridge-presence.js +100 -0
- package/dist/pi-intercom/bridge-protocol.js +7 -0
- package/dist/pi-intercom/bridge-routes.js +190 -17
- package/dist/pi-intercom/bridge-rpc-process.js +43 -15
- package/dist/pi-intercom/bridge-session.js +83 -85
- package/dist/pi-intercom/bridge-target.js +22 -0
- package/dist/pi-intercom/bridge-wake.js +158 -0
- package/dist/pi-intercom/bridge.js +84 -44
- package/dist/pi-intercom/broker/client.js +1 -0
- package/dist/pi-intercom/connection.js +2 -3
- package/dist/pi-intercom/inbound.js +2 -4
- package/dist/pi-intercom/index.js +7 -1
- package/dist/pi-intercom/native-control-auth.js +80 -0
- package/dist/pi-intercom/native-control-protocol.js +111 -0
- package/dist/pi-intercom/native-control.js +130 -0
- package/dist/pi-intercom/saved-transcript.js +135 -0
- package/dist/pi-intercom/session-targets.js +3 -0
- package/dist/pi-intercom/transcript.js +175 -0
- package/docs/intercom-bridge.md +293 -161
- package/docs/intercom.md +1 -1
- package/docs/reference.md +1 -1
- package/package.json +1 -1
- package/scripts/intercom-bridge-credentials.mjs +54 -12
- package/scripts/intercom-remote.mjs +196 -31
- package/scripts/package-smoke.mjs +1 -1
- package/src/pi-intercom/bridge-broadcast.ts +70 -0
- package/src/pi-intercom/bridge-catalog.ts +489 -0
- package/src/pi-intercom/bridge-config.ts +64 -37
- package/src/pi-intercom/bridge-control.ts +179 -0
- package/src/pi-intercom/bridge-files.ts +59 -0
- package/src/pi-intercom/bridge-groups.ts +169 -0
- package/src/pi-intercom/bridge-http.ts +15 -1
- package/src/pi-intercom/bridge-launch-journal.ts +122 -0
- package/src/pi-intercom/bridge-launch-policy.ts +88 -7
- package/src/pi-intercom/bridge-launch.ts +296 -107
- package/src/pi-intercom/bridge-mailbox.ts +395 -0
- package/src/pi-intercom/bridge-operations.ts +169 -0
- package/src/pi-intercom/bridge-presence.ts +128 -0
- package/src/pi-intercom/bridge-protocol.ts +8 -0
- package/src/pi-intercom/bridge-routes.ts +281 -32
- package/src/pi-intercom/bridge-rpc-process.ts +53 -16
- package/src/pi-intercom/bridge-session.ts +106 -105
- package/src/pi-intercom/bridge-target.ts +35 -0
- package/src/pi-intercom/bridge-wake.ts +188 -0
- package/src/pi-intercom/bridge.ts +88 -54
- package/src/pi-intercom/broker/client.ts +3 -0
- package/src/pi-intercom/connection.ts +2 -3
- package/src/pi-intercom/inbound.ts +2 -4
- package/src/pi-intercom/index.ts +7 -1
- package/src/pi-intercom/native-control-auth.ts +89 -0
- package/src/pi-intercom/native-control-protocol.ts +180 -0
- package/src/pi-intercom/native-control.ts +160 -0
- package/src/pi-intercom/saved-transcript.ts +175 -0
- package/src/pi-intercom/session-targets.ts +4 -0
- package/src/pi-intercom/transcript.ts +226 -0
- package/src/pi-intercom/types.ts +2 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { Message } from "./types.ts";
|
|
3
|
+
import { Fault, wait } from "./bridge-protocol.ts";
|
|
4
|
+
import type { IntercomClient } from "./broker/client.ts";
|
|
5
|
+
import type { TranscriptOptions } from "./transcript.ts";
|
|
6
|
+
import {
|
|
7
|
+
CONTROL_FALLBACK,
|
|
8
|
+
CONTROL_SCHEMA,
|
|
9
|
+
isControlResult,
|
|
10
|
+
type NativeControlResult,
|
|
11
|
+
type NativeControlVerb,
|
|
12
|
+
} from "./native-control-protocol.ts";
|
|
13
|
+
import type { UnknownRecord } from "../shared/unknown.ts";
|
|
14
|
+
|
|
15
|
+
interface Pending {
|
|
16
|
+
readonly peer: string;
|
|
17
|
+
readonly verb: NativeControlVerb;
|
|
18
|
+
readonly reply: Promise<NativeControlResult>;
|
|
19
|
+
readonly resolve: (result: NativeControlResult) => void;
|
|
20
|
+
readonly reject: (error: Readonly<Error>) => void;
|
|
21
|
+
readonly timer: NodeJS.Timeout;
|
|
22
|
+
disconnected: boolean;
|
|
23
|
+
}
|
|
24
|
+
/** One bounded request owner; exact socket-derived peer, replyTo, version and verb correlation. */
|
|
25
|
+
export class ControlReplies {
|
|
26
|
+
private readonly pending = new Map<string, Pending>();
|
|
27
|
+
private open(
|
|
28
|
+
peer: string,
|
|
29
|
+
verb: NativeControlVerb,
|
|
30
|
+
timeoutMs: number,
|
|
31
|
+
): {
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly reply: Promise<NativeControlResult>;
|
|
34
|
+
readonly clear: () => void;
|
|
35
|
+
readonly observedDisconnected: boolean;
|
|
36
|
+
} {
|
|
37
|
+
if (this.pending.size >= 8) {
|
|
38
|
+
throw new Fault(429, "too_many_controls", "At most eight native controls may be in flight.");
|
|
39
|
+
}
|
|
40
|
+
const id = randomUUID();
|
|
41
|
+
const { promise: reply, resolve, reject } = Promise.withResolvers<NativeControlResult>();
|
|
42
|
+
// A disconnect may reject while send is awaiting its broker receipt.
|
|
43
|
+
void reply.catch(() => {
|
|
44
|
+
/* Request owner observes rejection after its send receipt. */
|
|
45
|
+
});
|
|
46
|
+
const timer = setTimeout(
|
|
47
|
+
() =>
|
|
48
|
+
reject(
|
|
49
|
+
new Fault(
|
|
50
|
+
504,
|
|
51
|
+
"control_unconfirmed",
|
|
52
|
+
"No correlated native response. Owner may be old/unavailable; execution is unknown. Do not automatically retry.",
|
|
53
|
+
{ id, deliveryUnknown: true },
|
|
54
|
+
),
|
|
55
|
+
),
|
|
56
|
+
timeoutMs,
|
|
57
|
+
);
|
|
58
|
+
const pending = { peer, verb, reply, resolve, reject, timer, disconnected: false };
|
|
59
|
+
this.pending.set(id, pending);
|
|
60
|
+
return {
|
|
61
|
+
id,
|
|
62
|
+
reply,
|
|
63
|
+
clear: () => this.clear(id),
|
|
64
|
+
get observedDisconnected() {
|
|
65
|
+
return pending.disconnected;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
async request(
|
|
70
|
+
client: Readonly<Pick<IntercomClient, "send">>,
|
|
71
|
+
operation: {
|
|
72
|
+
readonly peer: string;
|
|
73
|
+
readonly verb: NativeControlVerb;
|
|
74
|
+
readonly options?: TranscriptOptions;
|
|
75
|
+
readonly certificate: string;
|
|
76
|
+
readonly timeoutMs: number;
|
|
77
|
+
},
|
|
78
|
+
context: {
|
|
79
|
+
readonly signal: AbortSignal;
|
|
80
|
+
readonly guard: () => void;
|
|
81
|
+
readonly onPeer: (peer: string) => void;
|
|
82
|
+
},
|
|
83
|
+
): Promise<UnknownRecord> {
|
|
84
|
+
context.onPeer(operation.peer);
|
|
85
|
+
const waiting = this.open(operation.peer, operation.verb, operation.timeoutMs);
|
|
86
|
+
try {
|
|
87
|
+
context.guard();
|
|
88
|
+
const receipt = await wait(
|
|
89
|
+
client.send(operation.peer, {
|
|
90
|
+
text: CONTROL_FALLBACK,
|
|
91
|
+
messageId: waiting.id,
|
|
92
|
+
delivery: "passive",
|
|
93
|
+
control: {
|
|
94
|
+
schema: CONTROL_SCHEMA,
|
|
95
|
+
kind: "request",
|
|
96
|
+
verb: operation.verb,
|
|
97
|
+
certificate: operation.certificate,
|
|
98
|
+
options: operation.options,
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
context.signal,
|
|
102
|
+
).catch((error: unknown) => {
|
|
103
|
+
context.guard();
|
|
104
|
+
if (error instanceof Fault) {
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
throw new Fault(
|
|
108
|
+
504,
|
|
109
|
+
"control_unconfirmed",
|
|
110
|
+
"Broker receipt was not received; native execution is unknown. Do not automatically retry.",
|
|
111
|
+
{ id: waiting.id, deliveryUnknown: true },
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
context.guard();
|
|
115
|
+
if (!receipt.accepted) {
|
|
116
|
+
throw new Fault(
|
|
117
|
+
409,
|
|
118
|
+
"delivery_failed",
|
|
119
|
+
receipt.reason ?? "Broker rejected native control.",
|
|
120
|
+
{ id: receipt.id },
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
const result = await wait(waiting.reply, context.signal);
|
|
124
|
+
context.guard();
|
|
125
|
+
return {
|
|
126
|
+
...receipt,
|
|
127
|
+
peer: operation.peer,
|
|
128
|
+
status: result.status,
|
|
129
|
+
native: result,
|
|
130
|
+
observedDisconnected: waiting.observedDisconnected,
|
|
131
|
+
processExited: false,
|
|
132
|
+
};
|
|
133
|
+
} finally {
|
|
134
|
+
waiting.clear();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
receive(peer: string, message: Message): boolean {
|
|
138
|
+
if (!Object.hasOwn(message, "control")) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
const pending = message.replyTo === undefined ? undefined : this.pending.get(message.replyTo);
|
|
142
|
+
if (
|
|
143
|
+
pending &&
|
|
144
|
+
pending.peer === peer &&
|
|
145
|
+
message.delivery === "passive" &&
|
|
146
|
+
isControlResult(message.control) &&
|
|
147
|
+
message.control.verb === pending.verb
|
|
148
|
+
) {
|
|
149
|
+
pending.resolve(message.control);
|
|
150
|
+
}
|
|
151
|
+
// Unsolicited/spoofed/malformed control results do not enter the remote mailbox.
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
peerLeft(peer: string): void {
|
|
155
|
+
for (const [id, pending] of this.pending) {
|
|
156
|
+
if (pending.peer === peer) {
|
|
157
|
+
pending.disconnected = true;
|
|
158
|
+
pending.reject(
|
|
159
|
+
new Fault(
|
|
160
|
+
409,
|
|
161
|
+
"peer_disconnected",
|
|
162
|
+
"Native peer disconnected; this does not establish process exit.",
|
|
163
|
+
{ id, observedDisconnected: true, processExited: false, deliveryUnknown: true },
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
close(error: Readonly<Error>): void {
|
|
170
|
+
for (const [id, pending] of this.pending) {
|
|
171
|
+
pending.reject(error);
|
|
172
|
+
this.clear(id);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
private clear(id: string): void {
|
|
176
|
+
clearTimeout(this.pending.get(id)?.timer);
|
|
177
|
+
this.pending.delete(id);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {
|
|
2
|
+
constants,
|
|
3
|
+
closeSync,
|
|
4
|
+
fstatSync,
|
|
5
|
+
fsyncSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { dirname } from "node:path";
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
|
|
15
|
+
export function readBridgeFile(path: string, privateFile: boolean, limit: number): Buffer {
|
|
16
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
17
|
+
try {
|
|
18
|
+
const stat = fstatSync(fd);
|
|
19
|
+
if (!stat.isFile() || stat.size > limit) {
|
|
20
|
+
throw new Error("Configuration/TLS file must be a bounded regular file.");
|
|
21
|
+
}
|
|
22
|
+
if (privateFile && (stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o600)) {
|
|
23
|
+
throw new Error("Config and TLS key must be owned by the current user with mode 0600.");
|
|
24
|
+
}
|
|
25
|
+
const data = readFileSync(fd);
|
|
26
|
+
if (data.length > limit) {
|
|
27
|
+
throw new Error("Configuration/TLS file exceeds its size limit.");
|
|
28
|
+
}
|
|
29
|
+
return data;
|
|
30
|
+
} finally {
|
|
31
|
+
closeSync(fd);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function writeBridgeJson(path: string, value: object): void {
|
|
36
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
37
|
+
const fd = openSync(
|
|
38
|
+
temporary,
|
|
39
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
|
40
|
+
0o600,
|
|
41
|
+
);
|
|
42
|
+
try {
|
|
43
|
+
try {
|
|
44
|
+
writeFileSync(fd, JSON.stringify(value));
|
|
45
|
+
fsyncSync(fd);
|
|
46
|
+
} finally {
|
|
47
|
+
closeSync(fd);
|
|
48
|
+
}
|
|
49
|
+
renameSync(temporary, path);
|
|
50
|
+
const directory = openSync(dirname(path), constants.O_RDONLY | constants.O_DIRECTORY);
|
|
51
|
+
try {
|
|
52
|
+
fsyncSync(directory);
|
|
53
|
+
} finally {
|
|
54
|
+
closeSync(directory);
|
|
55
|
+
}
|
|
56
|
+
} finally {
|
|
57
|
+
rmSync(temporary, { force: true });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { lstatSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { join, matchesGlob } from "node:path";
|
|
3
|
+
import { readBridgeFile, writeBridgeJson } from "./bridge-files.ts";
|
|
4
|
+
import { isUnknownArray, hasErrorCode } from "../shared/unknown.ts";
|
|
5
|
+
import { Fault, label, object } from "./bridge-protocol.ts";
|
|
6
|
+
import { resolveSessionTarget, nativePeerId } from "./session-targets.ts";
|
|
7
|
+
import type { SessionInfo } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
export interface GroupDefinition {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly members?: readonly string[];
|
|
12
|
+
readonly namePattern?: string;
|
|
13
|
+
readonly cwdPattern?: string;
|
|
14
|
+
}
|
|
15
|
+
const GROUP_LIMIT = 64;
|
|
16
|
+
function normalizedGroup(value: unknown): Record<string, unknown> {
|
|
17
|
+
const input = object(value, ["name", "members", "namePattern", "cwdPattern", "match"]);
|
|
18
|
+
if (input.match === undefined) {
|
|
19
|
+
return input;
|
|
20
|
+
}
|
|
21
|
+
if ([input.members, input.namePattern, input.cwdPattern].some((field) => field !== undefined)) {
|
|
22
|
+
throw new Fault(400, "invalid_input", "Use match or canonical selectors, not both.");
|
|
23
|
+
}
|
|
24
|
+
const match =
|
|
25
|
+
typeof input.match === "string"
|
|
26
|
+
? { nameglob: input.match }
|
|
27
|
+
: object(input.match, ["id", "nameglob", "cwd"]);
|
|
28
|
+
return {
|
|
29
|
+
name: input.name,
|
|
30
|
+
members: typeof match.id === "string" ? [match.id] : match.id,
|
|
31
|
+
namePattern: match.nameglob,
|
|
32
|
+
cwdPattern: match.cwd,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function groupDefinition(value: unknown): GroupDefinition {
|
|
36
|
+
const input = normalizedGroup(value);
|
|
37
|
+
const name = label(input.name, "group name", 64).trim();
|
|
38
|
+
if (
|
|
39
|
+
input.members !== undefined &&
|
|
40
|
+
(!isUnknownArray(input.members) || input.members.length > 256)
|
|
41
|
+
) {
|
|
42
|
+
throw new Fault(400, "invalid_input", "members must contain at most 256 peer IDs/names.");
|
|
43
|
+
}
|
|
44
|
+
const members = isUnknownArray(input.members)
|
|
45
|
+
? [...new Set(input.members.map((member) => label(member, "member", 512).trim()))]
|
|
46
|
+
: undefined;
|
|
47
|
+
const namePattern =
|
|
48
|
+
input.namePattern === undefined ? undefined : label(input.namePattern, "name pattern", 128);
|
|
49
|
+
const cwdPattern =
|
|
50
|
+
input.cwdPattern === undefined ? undefined : label(input.cwdPattern, "cwd pattern", 512);
|
|
51
|
+
if (members === undefined && namePattern === undefined && cwdPattern === undefined) {
|
|
52
|
+
throw new Fault(400, "invalid_input", "Define explicit members or at least one glob pattern.");
|
|
53
|
+
}
|
|
54
|
+
return { name, members, namePattern, cwdPattern };
|
|
55
|
+
}
|
|
56
|
+
function privateDirectory(path: string): void {
|
|
57
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
58
|
+
const stat = lstatSync(path);
|
|
59
|
+
if (
|
|
60
|
+
!stat.isDirectory() ||
|
|
61
|
+
stat.isSymbolicLink() ||
|
|
62
|
+
stat.uid !== process.getuid?.() ||
|
|
63
|
+
(stat.mode & 0o777) !== 0o700
|
|
64
|
+
) {
|
|
65
|
+
throw new Fault(
|
|
66
|
+
503,
|
|
67
|
+
"state_unavailable",
|
|
68
|
+
"Group stateDir must be owned by the current user with mode 0700.",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export function nativePeers(
|
|
73
|
+
peers: readonly SessionInfo[],
|
|
74
|
+
source?: string | null,
|
|
75
|
+
): readonly SessionInfo[] {
|
|
76
|
+
return peers.filter((peer) => /^pi-[a-f0-9]{32}$/.test(peer.id) && peer.id !== source);
|
|
77
|
+
}
|
|
78
|
+
export interface GroupTargets {
|
|
79
|
+
readonly peers: readonly SessionInfo[];
|
|
80
|
+
readonly errors: readonly { readonly member: string; readonly code: string }[];
|
|
81
|
+
}
|
|
82
|
+
export class Groups {
|
|
83
|
+
private readonly stateDir: string;
|
|
84
|
+
constructor(stateDir: string) {
|
|
85
|
+
this.stateDir = stateDir;
|
|
86
|
+
privateDirectory(stateDir);
|
|
87
|
+
}
|
|
88
|
+
private file(fingerprint: string): string {
|
|
89
|
+
if (!/^[a-fA-F0-9]{64}$/.test(fingerprint)) {
|
|
90
|
+
throw new Fault(403, "unauthorized", "Invalid identity.");
|
|
91
|
+
}
|
|
92
|
+
return join(this.stateDir, `groups-${fingerprint.toLowerCase()}.json`);
|
|
93
|
+
}
|
|
94
|
+
list(fingerprint: string): readonly GroupDefinition[] {
|
|
95
|
+
let raw: unknown;
|
|
96
|
+
try {
|
|
97
|
+
raw = JSON.parse(readBridgeFile(this.file(fingerprint), true, 1024 * 1024).toString("utf8"));
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (hasErrorCode(error, "ENOENT")) {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
if (!isUnknownArray(raw) || raw.length > GROUP_LIMIT) {
|
|
105
|
+
throw new Fault(503, "state_unavailable", "Invalid group state.");
|
|
106
|
+
}
|
|
107
|
+
return raw.map(groupDefinition);
|
|
108
|
+
}
|
|
109
|
+
upsert(fingerprint: string, group: GroupDefinition): readonly GroupDefinition[] {
|
|
110
|
+
privateDirectory(this.stateDir);
|
|
111
|
+
const groups = this.list(fingerprint).filter((entry) => entry.name !== group.name);
|
|
112
|
+
if (groups.length >= GROUP_LIMIT) {
|
|
113
|
+
throw new Fault(409, "group_limit", "At most 64 groups are allowed per identity.");
|
|
114
|
+
}
|
|
115
|
+
groups.push(groupDefinition(group));
|
|
116
|
+
const file = this.file(fingerprint);
|
|
117
|
+
const serialized = JSON.stringify(groups);
|
|
118
|
+
if (Buffer.byteLength(serialized) > 1024 * 1024) {
|
|
119
|
+
throw new Fault(
|
|
120
|
+
413,
|
|
121
|
+
"state_too_large",
|
|
122
|
+
"Group definitions exceed the 1 MiB identity budget.",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
writeBridgeJson(file, groups);
|
|
126
|
+
return groups;
|
|
127
|
+
}
|
|
128
|
+
targets(
|
|
129
|
+
fingerprint: string,
|
|
130
|
+
name: string | undefined,
|
|
131
|
+
peers: readonly SessionInfo[],
|
|
132
|
+
): GroupTargets {
|
|
133
|
+
if (name === undefined) {
|
|
134
|
+
return { peers: nativePeers(peers), errors: [] };
|
|
135
|
+
}
|
|
136
|
+
const group = this.list(fingerprint).find((entry) => entry.name === name);
|
|
137
|
+
if (!group) {
|
|
138
|
+
throw new Fault(404, "unknown_group", "Define this group before sending to it.");
|
|
139
|
+
}
|
|
140
|
+
return selectTargets(group, nativePeers(peers));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function selectTargets(group: GroupDefinition, peers: readonly SessionInfo[]): GroupTargets {
|
|
144
|
+
const selected = new Map<string, SessionInfo>();
|
|
145
|
+
const errors: { member: string; code: string }[] = [];
|
|
146
|
+
for (const member of group.members ?? []) {
|
|
147
|
+
const result = resolveSessionTarget(peers, member);
|
|
148
|
+
const target = result.target ?? peers.find((peer) => peer.id === nativePeerId(member));
|
|
149
|
+
if (target) {
|
|
150
|
+
selected.set(target.id, target);
|
|
151
|
+
} else {
|
|
152
|
+
errors.push({ member, code: result.status === "none" ? "peer_offline" : "ambiguous_target" });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (group.namePattern !== undefined || group.cwdPattern !== undefined) {
|
|
156
|
+
for (const peer of peers) {
|
|
157
|
+
if (matchesSelectors(group, peer)) {
|
|
158
|
+
selected.set(peer.id, peer);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { peers: [...selected.values()], errors };
|
|
163
|
+
}
|
|
164
|
+
function matchesSelectors(group: GroupDefinition, peer: SessionInfo): boolean {
|
|
165
|
+
return (
|
|
166
|
+
(group.namePattern === undefined || matchesGlob(peer.name ?? "", group.namePattern)) &&
|
|
167
|
+
(group.cwdPattern === undefined || matchesGlob(peer.cwd, group.cwdPattern))
|
|
168
|
+
);
|
|
169
|
+
}
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
-
import { BODY_LIMIT, Fault } from "./bridge-protocol.ts";
|
|
2
|
+
import { BODY_LIMIT, Fault, BroadcastFault } from "./bridge-protocol.ts";
|
|
3
3
|
import { errorMessage, type UnknownRecord } from "../shared/unknown.ts";
|
|
4
4
|
|
|
5
|
+
export interface RequestContext {
|
|
6
|
+
readonly signal: AbortSignal;
|
|
7
|
+
readonly requestId: string;
|
|
8
|
+
readonly onPeer: (peer: string) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
5
11
|
function validateHeaders(req: IncomingMessage): void {
|
|
6
12
|
const type = req.headers["content-type"];
|
|
7
13
|
if (typeof type !== "string" || !/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(type)) {
|
|
@@ -95,3 +101,11 @@ export function reply(
|
|
|
95
101
|
});
|
|
96
102
|
res.end(payload);
|
|
97
103
|
}
|
|
104
|
+
|
|
105
|
+
export function replyFault(res: ServerResponse, requestId: string, fault: Fault): void {
|
|
106
|
+
reply(res, requestId, fault.status, {
|
|
107
|
+
ok: false,
|
|
108
|
+
...(fault instanceof BroadcastFault ? fault.outcome : {}),
|
|
109
|
+
error: { code: fault.code, message: fault.message, ...fault.details },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import type { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { loadNativeSession } from "../shared/native-session-loader.ts";
|
|
4
|
+
import { isRecord, type UnknownRecord } from "../shared/unknown.ts";
|
|
5
|
+
import { Fault, cancelled, wait } from "./bridge-protocol.ts";
|
|
6
|
+
import { trustedJournal, inspectResumeIdentity } from "./bridge-catalog.ts";
|
|
7
|
+
import { nativePeerId } from "./session-targets.ts";
|
|
8
|
+
import type { LaunchPolicy } from "./bridge-launch-policy.ts";
|
|
9
|
+
|
|
10
|
+
export interface LaunchJournal {
|
|
11
|
+
readonly sessionId: string;
|
|
12
|
+
readonly peerId: string;
|
|
13
|
+
readonly sessionFile: string;
|
|
14
|
+
readonly name?: string;
|
|
15
|
+
readonly cwd: string;
|
|
16
|
+
readonly model?: { readonly provider: string; readonly modelId: string };
|
|
17
|
+
}
|
|
18
|
+
export function validateLaunchState(saved: LaunchJournal, state: UnknownRecord): void {
|
|
19
|
+
if (
|
|
20
|
+
state.sessionId !== saved.sessionId ||
|
|
21
|
+
state.sessionFile !== saved.sessionFile ||
|
|
22
|
+
state.sessionName !== saved.name ||
|
|
23
|
+
state.isStreaming !== false ||
|
|
24
|
+
state.pendingMessageCount !== 0 ||
|
|
25
|
+
!isRecord(state.model)
|
|
26
|
+
) {
|
|
27
|
+
throw new Fault(503, "readiness_failed", "Pi did not start idle with the exact saved session.");
|
|
28
|
+
}
|
|
29
|
+
if (
|
|
30
|
+
saved.model &&
|
|
31
|
+
(state.model.provider !== saved.model.provider || state.model.id !== saved.model.modelId)
|
|
32
|
+
) {
|
|
33
|
+
throw new Fault(
|
|
34
|
+
503,
|
|
35
|
+
"model_restore_failed",
|
|
36
|
+
"Pi could not restore the saved model exactly; no task submitted and no silent fallback allowed.",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function journal(manager: Readonly<SessionManager>): LaunchJournal {
|
|
41
|
+
const sessionFile = manager.getSessionFile();
|
|
42
|
+
if (sessionFile === undefined) {
|
|
43
|
+
throw new Fault(503, "journal_invalid", "Native journal was not persisted.");
|
|
44
|
+
}
|
|
45
|
+
const branch = manager.getBranch();
|
|
46
|
+
const change = branch.findLast((entry) => entry.type === "model_change");
|
|
47
|
+
const model =
|
|
48
|
+
change?.type === "model_change"
|
|
49
|
+
? { provider: change.provider, modelId: change.modelId }
|
|
50
|
+
: (manager.buildSessionContext().model ?? undefined);
|
|
51
|
+
return {
|
|
52
|
+
sessionId: manager.getSessionId(),
|
|
53
|
+
peerId: nativePeerId(manager.getSessionId()),
|
|
54
|
+
sessionFile,
|
|
55
|
+
name: manager.getSessionName(),
|
|
56
|
+
cwd: manager.getCwd(),
|
|
57
|
+
model,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export async function createLaunchJournal(
|
|
61
|
+
policy: LaunchPolicy,
|
|
62
|
+
target: { readonly cwd: string; readonly name: string },
|
|
63
|
+
signal: AbortSignal,
|
|
64
|
+
): Promise<LaunchJournal> {
|
|
65
|
+
const { SessionManager: Manager } = await wait(loadNativeSession(policy.packageRoot), signal);
|
|
66
|
+
cancelled(signal);
|
|
67
|
+
const storage = statSync(policy.sessionDir);
|
|
68
|
+
if (
|
|
69
|
+
!storage.isDirectory() ||
|
|
70
|
+
storage.uid !== process.getuid?.() ||
|
|
71
|
+
(storage.mode & 0o777) !== 0o700
|
|
72
|
+
) {
|
|
73
|
+
throw new Fault(503, "storage_invalid", "Private launch storage is no longer valid.");
|
|
74
|
+
}
|
|
75
|
+
const previous = process.umask(0o077);
|
|
76
|
+
try {
|
|
77
|
+
const manager = Manager.create(target.cwd, policy.sessionDir);
|
|
78
|
+
manager.appendSessionInfo(target.name);
|
|
79
|
+
if (policy.provider !== undefined && policy.model !== undefined) {
|
|
80
|
+
manager.appendModelChange(policy.provider, policy.model);
|
|
81
|
+
}
|
|
82
|
+
manager.appendMessage({ role: "user", content: policy.context, timestamp: Date.now() });
|
|
83
|
+
const result = journal(manager);
|
|
84
|
+
trustedJournal(policy, result.sessionFile);
|
|
85
|
+
if ((statSync(result.sessionFile).mode & 0o777) !== 0o600) {
|
|
86
|
+
throw new Fault(503, "journal_invalid", "New native journal was not privately persisted.");
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
} finally {
|
|
90
|
+
process.umask(previous);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export async function resumeLaunchJournal(
|
|
94
|
+
policy: LaunchPolicy,
|
|
95
|
+
saved: LaunchJournal,
|
|
96
|
+
signal: AbortSignal,
|
|
97
|
+
): Promise<LaunchJournal> {
|
|
98
|
+
const sdk = await wait(loadNativeSession(policy.packageRoot), signal);
|
|
99
|
+
cancelled(signal);
|
|
100
|
+
trustedJournal(policy, saved.sessionFile);
|
|
101
|
+
validateResumeIdentity(saved, await inspectResumeIdentity(saved.sessionFile, sdk, signal));
|
|
102
|
+
cancelled(signal);
|
|
103
|
+
const result = journal(sdk.SessionManager.open(saved.sessionFile));
|
|
104
|
+
validateResumeIdentity(saved, result);
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
function validateResumeIdentity(
|
|
108
|
+
saved: LaunchJournal,
|
|
109
|
+
actual: Pick<LaunchJournal, "sessionId" | "cwd" | "name">,
|
|
110
|
+
): void {
|
|
111
|
+
if (
|
|
112
|
+
actual.sessionId !== saved.sessionId ||
|
|
113
|
+
actual.cwd !== saved.cwd ||
|
|
114
|
+
actual.name !== saved.name
|
|
115
|
+
) {
|
|
116
|
+
throw new Fault(
|
|
117
|
+
409,
|
|
118
|
+
"journal_changed",
|
|
119
|
+
"Native session identity changed while preparing resume.",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { isAbsolute, join, relative } from "node:path";
|
|
3
3
|
import { isRecord, isUnknownArray } from "../shared/unknown.ts";
|
|
4
|
-
import { label, object, text } from "./bridge-protocol.ts";
|
|
4
|
+
import { Fault, label, object, text } from "./bridge-protocol.ts";
|
|
5
5
|
|
|
6
6
|
export interface LaunchPolicy {
|
|
7
7
|
readonly packageRoot: string;
|
|
8
8
|
readonly cli: string;
|
|
9
9
|
readonly sessionDir: string;
|
|
10
|
-
readonly provider
|
|
11
|
-
readonly model
|
|
10
|
+
readonly provider?: string;
|
|
11
|
+
readonly model?: string;
|
|
12
|
+
readonly workspaceRoots: readonly string[];
|
|
13
|
+
readonly resumeDirs: readonly string[];
|
|
12
14
|
readonly context: string;
|
|
13
15
|
readonly extensions: readonly string[];
|
|
14
16
|
readonly discoverResources: boolean;
|
|
@@ -52,8 +54,8 @@ function executable(packageRoot: string): string {
|
|
|
52
54
|
}
|
|
53
55
|
const bin = label(manifest.bin.pi, "Pi manifest bin", 4096);
|
|
54
56
|
const cli = realpathSync(join(packageRoot, bin));
|
|
55
|
-
const
|
|
56
|
-
if (
|
|
57
|
+
const suffix = relative(packageRoot, cli);
|
|
58
|
+
if (suffix.startsWith("..") || isAbsolute(suffix) || !statSync(cli).isFile()) {
|
|
57
59
|
throw new Error("Pi bin must be a file inside the pinned package.");
|
|
58
60
|
}
|
|
59
61
|
return cli;
|
|
@@ -65,6 +67,78 @@ function extensions(value: unknown): readonly string[] {
|
|
|
65
67
|
return value.map((entry) => absolute(entry, "extension path"));
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
function directories(value: unknown, owned = true): readonly string[] {
|
|
71
|
+
if (value === undefined) {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
if (!isUnknownArray(value) || value.length > 32) {
|
|
75
|
+
throw new Error("Launch roots must contain at most 32 local directories.");
|
|
76
|
+
}
|
|
77
|
+
return [
|
|
78
|
+
...new Set(
|
|
79
|
+
value.map((entry) => {
|
|
80
|
+
const path = absolute(entry, "trusted root");
|
|
81
|
+
const stat = lstatSync(path);
|
|
82
|
+
if (!stat.isDirectory() || (owned && stat.uid !== process.getuid?.())) {
|
|
83
|
+
throw new Error("Launch roots must be owned directories.");
|
|
84
|
+
}
|
|
85
|
+
return path;
|
|
86
|
+
}),
|
|
87
|
+
),
|
|
88
|
+
];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function within(root: string, path: string): boolean {
|
|
92
|
+
const suffix = relative(root, path);
|
|
93
|
+
return (
|
|
94
|
+
suffix !== ".." &&
|
|
95
|
+
!suffix.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) &&
|
|
96
|
+
!isAbsolute(suffix)
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
export function launchCwd(policy: LaunchPolicy, requested: string): string {
|
|
100
|
+
if (!isAbsolute(requested)) {
|
|
101
|
+
throw new Fault(400, "invalid_cwd", "cwd must be an absolute approved workspace directory.");
|
|
102
|
+
}
|
|
103
|
+
let cwd: string;
|
|
104
|
+
try {
|
|
105
|
+
cwd = realpathSync(requested);
|
|
106
|
+
if (!statSync(cwd).isDirectory()) {
|
|
107
|
+
throw new Error("Not a directory");
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
throw new Fault(400, "invalid_cwd", "cwd must be an existing workspace directory.");
|
|
111
|
+
}
|
|
112
|
+
if (!policy.workspaceRoots.some((root) => within(root, cwd))) {
|
|
113
|
+
throw new Fault(403, "cwd_forbidden", "cwd is outside locally approved workspaceRoots.");
|
|
114
|
+
}
|
|
115
|
+
return cwd;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function spawnPolicy(
|
|
119
|
+
policy: LaunchPolicy,
|
|
120
|
+
input: { readonly provider?: string; readonly model?: string },
|
|
121
|
+
): LaunchPolicy {
|
|
122
|
+
if (input.model === undefined) {
|
|
123
|
+
if (input.provider !== undefined) {
|
|
124
|
+
throw new Fault(400, "invalid_input", "Spawn provider requires model.");
|
|
125
|
+
}
|
|
126
|
+
return policy;
|
|
127
|
+
}
|
|
128
|
+
// Native provider/model convention splits at the first slash; model IDs may contain further slashes.
|
|
129
|
+
const slash = input.model.indexOf("/");
|
|
130
|
+
const provider = input.provider ?? (slash > 0 ? input.model.slice(0, slash) : policy.provider);
|
|
131
|
+
const model =
|
|
132
|
+
input.provider === undefined && slash > 0 ? input.model.slice(slash + 1) : input.model;
|
|
133
|
+
if (provider === undefined || provider.trim() === "" || model.trim() === "") {
|
|
134
|
+
throw new Fault(
|
|
135
|
+
400,
|
|
136
|
+
"invalid_input",
|
|
137
|
+
"Use an exact provider/model reference or a provider and model pair.",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return { ...policy, provider: provider.trim(), model: model.trim() };
|
|
141
|
+
}
|
|
68
142
|
export function launchPolicy(value: unknown): LaunchPolicy | undefined {
|
|
69
143
|
if (value === undefined) {
|
|
70
144
|
return;
|
|
@@ -81,6 +155,8 @@ export function launchPolicy(value: unknown): LaunchPolicy | undefined {
|
|
|
81
155
|
"offline",
|
|
82
156
|
"maxSessions",
|
|
83
157
|
"startupTimeoutMs",
|
|
158
|
+
"workspaceRoots",
|
|
159
|
+
"resumeDirs",
|
|
84
160
|
]);
|
|
85
161
|
const packageRoot = absolute(raw.packageRoot, "Pi package root");
|
|
86
162
|
const sessionDir = absolute(raw.sessionDir, "session directory");
|
|
@@ -92,12 +168,17 @@ export function launchPolicy(value: unknown): LaunchPolicy | undefined {
|
|
|
92
168
|
) {
|
|
93
169
|
throw new Error("Launch storage must be an owned directory with mode 0700.");
|
|
94
170
|
}
|
|
171
|
+
if ((raw.provider === undefined) !== (raw.model === undefined)) {
|
|
172
|
+
throw new Error("Launch provider and model must both be supplied or both omitted.");
|
|
173
|
+
}
|
|
95
174
|
return {
|
|
96
175
|
packageRoot,
|
|
97
176
|
cli: executable(packageRoot),
|
|
98
177
|
sessionDir,
|
|
99
|
-
provider: label(raw.provider, "provider", 128),
|
|
100
|
-
model: label(raw.model, "model", 256),
|
|
178
|
+
provider: raw.provider === undefined ? undefined : label(raw.provider, "provider", 128),
|
|
179
|
+
model: raw.model === undefined ? undefined : label(raw.model, "model", 256),
|
|
180
|
+
workspaceRoots: directories(raw.workspaceRoots, false),
|
|
181
|
+
resumeDirs: directories(raw.resumeDirs),
|
|
101
182
|
context: text(raw.context),
|
|
102
183
|
extensions: extensions(raw.extensions),
|
|
103
184
|
discoverResources: boolean(raw.discoverResources, false),
|