@vellumai/cli 0.11.3 → 0.11.4-dev.202608190019.b94dbf2
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/node_modules/@vellumai/local-mode/src/__tests__/unpair.test.ts +33 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +3 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-lock.test.ts +165 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-lock.ts +156 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +249 -8
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +186 -68
- package/node_modules/@vellumai/local-mode/src/unpair.ts +18 -0
- package/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/url-normalization.test.ts +135 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
- package/node_modules/@vellumai/service-contracts/src/url-normalization.ts +107 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +35 -0
- package/src/__tests__/nginx-ingress-command.test.ts +4 -23
- package/src/__tests__/nginx-ingress.test.ts +59 -215
- package/src/__tests__/pair.test.ts +11 -197
- package/src/__tests__/retire-archive.test.ts +13 -1
- package/src/__tests__/retire-local.test.ts +58 -4
- package/src/__tests__/tunnel.test.ts +0 -28
- package/src/__tests__/wake.test.ts +91 -69
- package/src/__tests__/windows-lifecycle.test.ts +157 -0
- package/src/commands/client.ts +9 -31
- package/src/commands/nginx-ingress.ts +0 -15
- package/src/commands/pair.ts +0 -39
- package/src/commands/wake.ts +39 -12
- package/src/lib/__tests__/web-dist.test.ts +86 -0
- package/src/lib/assistant-config.ts +64 -27
- package/src/lib/local.ts +60 -20
- package/src/lib/nginx-ingress.ts +35 -108
- package/src/lib/orphan-detection.test.ts +3 -0
- package/src/lib/orphan-detection.ts +33 -11
- package/src/lib/pgrep.ts +20 -2
- package/src/lib/process.ts +191 -18
- package/src/lib/retire-archive.ts +38 -8
- package/src/lib/retire-local.ts +75 -9
- package/src/lib/tunnel-edge.ts +14 -22
- package/src/lib/web-dist.ts +48 -0
- package/src/lib/feature-flags.test.ts +0 -157
- package/src/lib/feature-flags.ts +0 -38
|
@@ -6,11 +6,11 @@ import {
|
|
|
6
6
|
resolveCloud,
|
|
7
7
|
type Lockfile,
|
|
8
8
|
} from "./lockfile-contract";
|
|
9
|
+
import { withLockfileLock } from "./lockfile-lock";
|
|
9
10
|
import { stripSensitiveFields } from "./util";
|
|
10
11
|
|
|
11
12
|
export type LockfileResult =
|
|
12
|
-
|
|
13
|
-
| { ok: false; status: number; error?: string };
|
|
13
|
+
{ ok: true; data: Lockfile } | { ok: false; status: number; error?: string };
|
|
14
14
|
|
|
15
15
|
export function getLockfileData(lockfilePaths: string[]): LockfileResult {
|
|
16
16
|
let raw: string | undefined;
|
|
@@ -63,6 +63,11 @@ export type WriteResult =
|
|
|
63
63
|
| { ok: true; lockfile: Lockfile }
|
|
64
64
|
| { ok: false; status: number; error: string };
|
|
65
65
|
|
|
66
|
+
/** 423 Locked: the cross-process advisory lock could not be acquired. */
|
|
67
|
+
function lockFailure(error: string): WriteResult {
|
|
68
|
+
return { ok: false, status: 423, error };
|
|
69
|
+
}
|
|
70
|
+
|
|
66
71
|
/**
|
|
67
72
|
* Read the first parseable lockfile as raw JSON (unknown fields intact) for a
|
|
68
73
|
* read-modify-write cycle; an unreadable file yields an empty lockfile.
|
|
@@ -83,6 +88,56 @@ export function readRawLockfile(
|
|
|
83
88
|
return { assistants: [], activeAssistant: null };
|
|
84
89
|
}
|
|
85
90
|
|
|
91
|
+
export type RawLockfileReadResult =
|
|
92
|
+
| { ok: true; lockfile: Record<string, unknown> }
|
|
93
|
+
| { ok: false; error: string };
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Like {@link readRawLockfile}, but a file that exists and cannot be read or
|
|
97
|
+
* parsed refuses instead of degrading to the empty registry. Missing or empty
|
|
98
|
+
* files still yield the empty registry. Use for writes that must never
|
|
99
|
+
* replace a registry they could not actually read.
|
|
100
|
+
*/
|
|
101
|
+
export function readRawLockfileStrict(
|
|
102
|
+
lockfilePaths: string[],
|
|
103
|
+
): RawLockfileReadResult {
|
|
104
|
+
for (const candidate of lockfilePaths) {
|
|
105
|
+
let raw: string;
|
|
106
|
+
try {
|
|
107
|
+
raw = fs.readFileSync(candidate, "utf-8");
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
110
|
+
return { ok: false, error: `Failed to read lockfile: ${err}` };
|
|
111
|
+
}
|
|
112
|
+
if (raw.trim() === "") continue;
|
|
113
|
+
let parsed: unknown;
|
|
114
|
+
try {
|
|
115
|
+
parsed = JSON.parse(raw);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
return { ok: false, error: `Failed to parse lockfile: ${err}` };
|
|
118
|
+
}
|
|
119
|
+
if (
|
|
120
|
+
typeof parsed !== "object" ||
|
|
121
|
+
parsed === null ||
|
|
122
|
+
Array.isArray(parsed)
|
|
123
|
+
) {
|
|
124
|
+
return { ok: false, error: "Lockfile is not a JSON object" };
|
|
125
|
+
}
|
|
126
|
+
return { ok: true, lockfile: parsed as Record<string, unknown> };
|
|
127
|
+
}
|
|
128
|
+
return { ok: true, lockfile: { assistants: [], activeAssistant: null } };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The validated, sensitive-field-stripped wire view of a raw lockfile. */
|
|
132
|
+
function toWireLockfile(lockfile: Record<string, unknown>): Lockfile {
|
|
133
|
+
const stripped = JSON.parse(JSON.stringify(lockfile)) as Record<
|
|
134
|
+
string,
|
|
135
|
+
unknown
|
|
136
|
+
>;
|
|
137
|
+
stripSensitiveFields(stripped);
|
|
138
|
+
return parseLockfile(stripped);
|
|
139
|
+
}
|
|
140
|
+
|
|
86
141
|
/**
|
|
87
142
|
* Atomically persist a raw lockfile (write-to-temp + rename) and return the
|
|
88
143
|
* validated, sensitive-field-stripped view of what was written.
|
|
@@ -99,15 +154,63 @@ export function writeRawLockfile(
|
|
|
99
154
|
fs.writeFileSync(tmp, JSON.stringify(lockfile, null, 2));
|
|
100
155
|
fs.renameSync(tmp, writePath);
|
|
101
156
|
} catch (err) {
|
|
102
|
-
return {
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
status: 500,
|
|
160
|
+
error: `Failed to write lockfile: ${err}`,
|
|
161
|
+
};
|
|
103
162
|
}
|
|
104
163
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
164
|
+
return { ok: true, lockfile: toWireLockfile(lockfile) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Rename an existing assistant entry, never creating one. Unlike the upserts,
|
|
169
|
+
* a missing entry refuses (404) and an unreadable on-disk file refuses (409)
|
|
170
|
+
* instead of being treated as an empty registry, so a stale renderer cache
|
|
171
|
+
* can neither resurrect a retired assistant nor replace a registry it could
|
|
172
|
+
* not read with a skeleton row. Never touches `activeAssistant`. The whole
|
|
173
|
+
* read-check-write runs under the shared advisory lock so a concurrent writer
|
|
174
|
+
* (e.g. a CLI retire) cannot be clobbered by this snapshot; lock contention
|
|
175
|
+
* refuses (423) without writing.
|
|
176
|
+
*/
|
|
177
|
+
export function renameLockfileAssistantIfPresent(
|
|
178
|
+
lockfilePaths: string[],
|
|
179
|
+
assistantId: string,
|
|
180
|
+
name: string,
|
|
181
|
+
): WriteResult {
|
|
182
|
+
if (typeof assistantId !== "string" || assistantId === "") {
|
|
183
|
+
return { ok: false, status: 400, error: "Missing assistantId" };
|
|
184
|
+
}
|
|
185
|
+
if (typeof name !== "string" || name === "") {
|
|
186
|
+
return { ok: false, status: 400, error: "Missing name" };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const locked = withLockfileLock(lockfilePaths, (): WriteResult => {
|
|
190
|
+
const read = readRawLockfileStrict(lockfilePaths);
|
|
191
|
+
if (!read.ok) {
|
|
192
|
+
return { ok: false, status: 409, error: read.error };
|
|
193
|
+
}
|
|
194
|
+
const lockfile = read.lockfile;
|
|
195
|
+
const assistants = Array.isArray(lockfile.assistants)
|
|
196
|
+
? (lockfile.assistants as Array<Record<string, unknown>>)
|
|
197
|
+
: [];
|
|
198
|
+
const idx = assistants.findIndex((a) => a?.assistantId === assistantId);
|
|
199
|
+
if (idx < 0) {
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
status: 404,
|
|
203
|
+
error: "No lockfile entry for this assistant",
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
if (assistants[idx]!.name === name) {
|
|
207
|
+
return { ok: true, lockfile: toWireLockfile(lockfile) };
|
|
208
|
+
}
|
|
209
|
+
assistants[idx] = { ...assistants[idx], name };
|
|
210
|
+
lockfile.assistants = assistants;
|
|
211
|
+
return writeRawLockfile(lockfilePaths, lockfile);
|
|
212
|
+
});
|
|
213
|
+
return locked.ok ? locked.value : lockFailure(locked.error);
|
|
111
214
|
}
|
|
112
215
|
|
|
113
216
|
export function upsertLockfileAssistant(
|
|
@@ -119,22 +222,27 @@ export function upsertLockfileAssistant(
|
|
|
119
222
|
return { ok: false, status: 400, error: "Missing assistant.assistantId" };
|
|
120
223
|
}
|
|
121
224
|
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
225
|
+
const locked = withLockfileLock(lockfilePaths, (): WriteResult => {
|
|
226
|
+
const lockfile = readRawLockfile(lockfilePaths);
|
|
227
|
+
const assistants = Array.isArray(lockfile.assistants)
|
|
228
|
+
? lockfile.assistants
|
|
229
|
+
: [];
|
|
230
|
+
const existingIdx = assistants.findIndex(
|
|
231
|
+
(a: Record<string, unknown>) => a?.assistantId === assistant.assistantId,
|
|
232
|
+
);
|
|
233
|
+
if (existingIdx >= 0) {
|
|
234
|
+
assistants[existingIdx] = { ...assistants[existingIdx], ...assistant };
|
|
235
|
+
} else {
|
|
236
|
+
assistants.push(assistant);
|
|
237
|
+
}
|
|
238
|
+
lockfile.assistants = assistants;
|
|
239
|
+
if (activeAssistant !== undefined) {
|
|
240
|
+
lockfile.activeAssistant = activeAssistant;
|
|
241
|
+
}
|
|
136
242
|
|
|
137
|
-
|
|
243
|
+
return writeRawLockfile(lockfilePaths, lockfile);
|
|
244
|
+
});
|
|
245
|
+
return locked.ok ? locked.value : lockFailure(locked.error);
|
|
138
246
|
}
|
|
139
247
|
|
|
140
248
|
const PAIRED_LOCKFILE_WRITE_ERROR =
|
|
@@ -154,33 +262,38 @@ export function upsertRendererLockfileAssistant(
|
|
|
154
262
|
return { ok: false, status: 400, error: "Missing assistant.assistantId" };
|
|
155
263
|
}
|
|
156
264
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
265
|
+
// Lock spans the paired-guard read and the upsert (which reenters) so the
|
|
266
|
+
// guard cannot be judged against a snapshot another writer replaces.
|
|
267
|
+
const locked = withLockfileLock(lockfilePaths, (): WriteResult => {
|
|
268
|
+
const lockfile = readRawLockfile(lockfilePaths);
|
|
269
|
+
const assistants = Array.isArray(lockfile.assistants)
|
|
270
|
+
? (lockfile.assistants as Array<Record<string, unknown>>)
|
|
271
|
+
: [];
|
|
272
|
+
const existing = assistants.find(
|
|
273
|
+
(entry) => entry?.assistantId === assistant.assistantId,
|
|
274
|
+
);
|
|
275
|
+
const merged = { ...existing, ...assistant };
|
|
276
|
+
const existingIsPaired =
|
|
277
|
+
existing != null &&
|
|
278
|
+
(resolveCloud(existing) === "paired" || existing.paired === true);
|
|
279
|
+
const mergedIsPaired =
|
|
280
|
+
resolveCloud(merged) === "paired" || merged.paired === true;
|
|
281
|
+
|
|
282
|
+
if (!existingIsPaired && mergedIsPaired) {
|
|
283
|
+
return { ok: false, status: 403, error: PAIRED_LOCKFILE_WRITE_ERROR };
|
|
284
|
+
}
|
|
285
|
+
if (
|
|
286
|
+
existingIsPaired &&
|
|
287
|
+
(resolveCloud(merged) !== "paired" ||
|
|
288
|
+
merged.runtimeUrl !== existing.runtimeUrl ||
|
|
289
|
+
merged.paired !== existing.paired)
|
|
290
|
+
) {
|
|
291
|
+
return { ok: false, status: 403, error: PAIRED_LOCKFILE_WRITE_ERROR };
|
|
292
|
+
}
|
|
182
293
|
|
|
183
|
-
|
|
294
|
+
return upsertLockfileAssistant(lockfilePaths, assistant, activeAssistant);
|
|
295
|
+
});
|
|
296
|
+
return locked.ok ? locked.value : lockFailure(locked.error);
|
|
184
297
|
}
|
|
185
298
|
|
|
186
299
|
export function isActiveAssistant(
|
|
@@ -226,24 +339,29 @@ export function replacePlatformAssistants(
|
|
|
226
339
|
};
|
|
227
340
|
}
|
|
228
341
|
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
342
|
+
const locked = withLockfileLock(lockfilePaths, (): WriteResult => {
|
|
343
|
+
const lockfile = readRawLockfile(lockfilePaths);
|
|
344
|
+
const existing = Array.isArray(lockfile.assistants)
|
|
345
|
+
? lockfile.assistants
|
|
346
|
+
: [];
|
|
347
|
+
const syncedIds = new Set(platformAssistants.map((a) => a.assistantId));
|
|
348
|
+
// Org-scoped sync preserves other orgs' platform entries; no org full-replaces.
|
|
349
|
+
const preserved = existing.filter((a: Record<string, unknown>) => {
|
|
350
|
+
if (a?.cloud !== "vellum") return true;
|
|
351
|
+
if (syncedIds.has(a.assistantId)) return false;
|
|
352
|
+
return organizationId != null && a.organizationId !== organizationId;
|
|
353
|
+
});
|
|
354
|
+
lockfile.assistants = [...preserved, ...platformAssistants];
|
|
239
355
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
356
|
+
const active = lockfile.activeAssistant as string | null;
|
|
357
|
+
if (active) {
|
|
358
|
+
const stillExists = (
|
|
359
|
+
lockfile.assistants as Array<Record<string, unknown>>
|
|
360
|
+
).some((a) => a.assistantId === active);
|
|
361
|
+
if (!stillExists) lockfile.activeAssistant = null;
|
|
362
|
+
}
|
|
247
363
|
|
|
248
|
-
|
|
364
|
+
return writeRawLockfile(lockfilePaths, lockfile);
|
|
365
|
+
});
|
|
366
|
+
return locked.ok ? locked.value : lockFailure(locked.error);
|
|
249
367
|
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type WriteResult,
|
|
9
9
|
} from "./lockfile";
|
|
10
10
|
import { resolveCloud } from "./lockfile-contract";
|
|
11
|
+
import { withLockfileLock } from "./lockfile-lock";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Forget a paired assistant on this machine: remove its lockfile entry and
|
|
@@ -21,6 +22,23 @@ export function unpairAssistant(
|
|
|
21
22
|
lockfilePaths: string[],
|
|
22
23
|
configDir: string,
|
|
23
24
|
assistantId: string,
|
|
25
|
+
): WriteResult {
|
|
26
|
+
// The whole transaction holds the shared write lock so a concurrent
|
|
27
|
+
// read-modify-write (e.g. a persona-name rename) cannot restore the entry
|
|
28
|
+
// after its credential is deleted.
|
|
29
|
+
const locked = withLockfileLock(lockfilePaths, (): WriteResult =>
|
|
30
|
+
unpairAssistantLocked(lockfilePaths, configDir, assistantId),
|
|
31
|
+
);
|
|
32
|
+
if (!locked.ok) {
|
|
33
|
+
return { ok: false, status: 423, error: locked.error };
|
|
34
|
+
}
|
|
35
|
+
return locked.value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function unpairAssistantLocked(
|
|
39
|
+
lockfilePaths: string[],
|
|
40
|
+
configDir: string,
|
|
41
|
+
assistantId: string,
|
|
24
42
|
): WriteResult {
|
|
25
43
|
const lockfile = readRawLockfile(lockfilePaths);
|
|
26
44
|
const assistants = Array.isArray(lockfile.assistants)
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"./remote-web-pairing": "./src/remote-web-pairing.ts",
|
|
15
15
|
"./twilio-ingress": "./src/twilio-ingress.ts",
|
|
16
16
|
"./trust-rules": "./src/trust-rules.ts",
|
|
17
|
+
"./url-normalization": "./src/url-normalization.ts",
|
|
17
18
|
"./handles": "./src/handles.ts",
|
|
18
19
|
"./rpc": "./src/rpc.ts",
|
|
19
20
|
"./attachment-naming": "./src/attachment-naming.ts",
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
canonicalizeWebUrl,
|
|
5
|
+
looksLikeHostPortShorthand,
|
|
6
|
+
looksLikePathOnlyInput,
|
|
7
|
+
normalizeWebUrl,
|
|
8
|
+
} from "../url-normalization.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* These are the rules a trust rule is saved and matched under. A change here
|
|
12
|
+
* changes which saved rules still match, so each case states the target the
|
|
13
|
+
* two spellings must agree on.
|
|
14
|
+
*/
|
|
15
|
+
describe("normalizeWebUrl", () => {
|
|
16
|
+
test("keeps an ordinary https URL as-is", () => {
|
|
17
|
+
expect(normalizeWebUrl("https://example.com/docs/page")?.href).toBe(
|
|
18
|
+
"https://example.com/docs/page",
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("drops the fragment: it never reaches the server", () => {
|
|
23
|
+
expect(normalizeWebUrl("https://example.com/docs#section")?.href).toBe(
|
|
24
|
+
"https://example.com/docs",
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("drops userinfo so credentials cannot land in a saved rule", () => {
|
|
29
|
+
const credentialed = new URL("https://example.com/docs/page");
|
|
30
|
+
credentialed.username = "demo";
|
|
31
|
+
credentialed.password = ["c", "r", "e", "d", "1", "2", "3"].join("");
|
|
32
|
+
|
|
33
|
+
const normalized = normalizeWebUrl(credentialed.href);
|
|
34
|
+
expect(normalized?.href).toBe("https://example.com/docs/page");
|
|
35
|
+
expect(normalized?.username).toBe("");
|
|
36
|
+
expect(normalized?.password).toBe("");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("strips a trailing root dot from the hostname", () => {
|
|
40
|
+
expect(normalizeWebUrl("https://example.com./docs/page")?.href).toBe(
|
|
41
|
+
"https://example.com/docs/page",
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("decodes escaped path segments so one path has one spelling", () => {
|
|
46
|
+
// Without this, a rule scoped to /private is bypassed by /%70rivate.
|
|
47
|
+
expect(normalizeWebUrl("https://example.com/%70rivate")?.href).toBe(
|
|
48
|
+
"https://example.com/private",
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("reads scheme-less input as https", () => {
|
|
53
|
+
expect(normalizeWebUrl("example.com/docs")?.href).toBe(
|
|
54
|
+
"https://example.com/docs",
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("reads host:port shorthand as an https origin, not a scheme", () => {
|
|
59
|
+
expect(normalizeWebUrl("example.com:8443/status")?.origin).toBe(
|
|
60
|
+
"https://example.com:8443",
|
|
61
|
+
);
|
|
62
|
+
expect(normalizeWebUrl("[2001:db8::1]:8443/status")?.origin).toBe(
|
|
63
|
+
"https://[2001:db8::1]:8443",
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("rejects path-only input rather than coercing it to a host", () => {
|
|
68
|
+
for (const input of ["/etc/passwd", "./rel", "../up", "?q=1", "#frag"]) {
|
|
69
|
+
expect(normalizeWebUrl(input)).toBeNull();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("rejects every non-http scheme", () => {
|
|
74
|
+
for (const input of [
|
|
75
|
+
"file:///etc/passwd",
|
|
76
|
+
"data:text/html,<script>",
|
|
77
|
+
"javascript:alert(1)",
|
|
78
|
+
"ftp://example.com/f",
|
|
79
|
+
]) {
|
|
80
|
+
expect(normalizeWebUrl(input)).toBeNull();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("rejects empty and whitespace-only input", () => {
|
|
85
|
+
expect(normalizeWebUrl("")).toBeNull();
|
|
86
|
+
expect(normalizeWebUrl(" ")).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("trims surrounding whitespace", () => {
|
|
90
|
+
expect(normalizeWebUrl(" https://example.com/a ")?.href).toBe(
|
|
91
|
+
"https://example.com/a",
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("returns null rather than throwing on an unparseable authority", () => {
|
|
96
|
+
expect(normalizeWebUrl("https://")).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("canonicalizeWebUrl", () => {
|
|
101
|
+
test("keeps the parser's form when the path is not decodable, without throwing", () => {
|
|
102
|
+
// `%zz` and a truncated UTF-8 sequence are not valid escapes; the path
|
|
103
|
+
// must survive unchanged rather than the call throwing.
|
|
104
|
+
expect(canonicalizeWebUrl(new URL("https://example.com/100%zz")).href).toBe(
|
|
105
|
+
"https://example.com/100%zz",
|
|
106
|
+
);
|
|
107
|
+
expect(
|
|
108
|
+
canonicalizeWebUrl(new URL("https://example.com/%E0%A4%A")).href,
|
|
109
|
+
).toBe("https://example.com/%E0%A4%A");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("decodes an escaped percent, so `%25` and `%` are one path", () => {
|
|
113
|
+
expect(canonicalizeWebUrl(new URL("https://example.com/100%25")).href).toBe(
|
|
114
|
+
"https://example.com/100%",
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("input shape predicates", () => {
|
|
120
|
+
test("host:port shorthand is recognized, scheme-prefixed input is not", () => {
|
|
121
|
+
expect(looksLikeHostPortShorthand("example.com:8443/x")).toBe(true);
|
|
122
|
+
expect(looksLikeHostPortShorthand("[2001:db8::1]:443")).toBe(true);
|
|
123
|
+
expect(looksLikeHostPortShorthand("https://example.com/x")).toBe(false);
|
|
124
|
+
expect(looksLikeHostPortShorthand("example.com/x")).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("path-only input is recognized", () => {
|
|
128
|
+
expect(looksLikePathOnlyInput("/abs")).toBe(true);
|
|
129
|
+
expect(looksLikePathOnlyInput("./rel")).toBe(true);
|
|
130
|
+
expect(looksLikePathOnlyInput("../up")).toBe(true);
|
|
131
|
+
expect(looksLikePathOnlyInput("?q=1")).toBe(true);
|
|
132
|
+
expect(looksLikePathOnlyInput("#frag")).toBe(true);
|
|
133
|
+
expect(looksLikePathOnlyInput("example.com/x")).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -6,6 +6,16 @@
|
|
|
6
6
|
* assistant through (Slack, Telegram, WhatsApp, phone, …) plus a couple of
|
|
7
7
|
* internal ids (`vellum` for native app conversations, `platform` for the
|
|
8
8
|
* internal control plane). This is the single source of truth for that set:
|
|
9
|
+
*
|
|
10
|
+
* One id, `plugin`, does not name a surface: it names *every* surface a plugin
|
|
11
|
+
* brings. A plugin channel's real identity is the plugin, which is workspace
|
|
12
|
+
* state and cannot be a compile-time union member, so the plugin name travels
|
|
13
|
+
* in `sourceMetadata.plugin` and is prefixed onto every external id the gateway
|
|
14
|
+
* forwards (`imessage:+15551234567`). Two plugins therefore share a channel
|
|
15
|
+
* row — one admission floor, one set of channel-wide defaults — while their
|
|
16
|
+
* conversations, contacts, and trust records stay disjoint. See
|
|
17
|
+
* `gateway/src/channels/plugin-inbound.ts` for what that concedes.
|
|
18
|
+
*
|
|
9
19
|
* the assistant adopts it wholesale as its `ChannelId`, and the gateway
|
|
10
20
|
* asserts its own (narrower) inbound list is a subset of it so the two sides
|
|
11
21
|
* cannot silently drift.
|
|
@@ -30,6 +40,7 @@ export const CHANNEL_IDS = [
|
|
|
30
40
|
"platform",
|
|
31
41
|
"a2a",
|
|
32
42
|
"discord",
|
|
43
|
+
"plugin",
|
|
33
44
|
] as const;
|
|
34
45
|
|
|
35
46
|
export type ChannelId = (typeof CHANNEL_IDS)[number];
|
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* (`gateway/src/http/routes/remote-web-pairing-verification.ts`)
|
|
12
12
|
* - `POST /v1/remote-web/pairing-token` poll + exchange device code
|
|
13
13
|
* (`gateway/src/http/routes/remote-web-pairing-token.ts`)
|
|
14
|
+
* - `GET /v1/remote-web/pairing-requests` list pending challenges
|
|
15
|
+
* (loopback-only)
|
|
16
|
+
* - `POST /v1/remote-web/pairing-requests/approve` approve by request id
|
|
17
|
+
* (loopback-only)
|
|
18
|
+
* - `POST /v1/remote-web/pairing-requests/deny` deny (delete) by request id
|
|
19
|
+
* (loopback-only)
|
|
14
20
|
*
|
|
15
21
|
* These shapes mirror those handlers' request/response bodies exactly so the
|
|
16
22
|
* gateway, the `vellum pair` CLI (`cli/src/commands/pair.ts`), and the web SPA
|
|
@@ -68,6 +74,60 @@ export interface RemoteWebPairingVerificationResponse {
|
|
|
68
74
|
expiresAt: string;
|
|
69
75
|
}
|
|
70
76
|
|
|
77
|
+
/**
|
|
78
|
+
* One pending challenge as shown on a host approval surface.
|
|
79
|
+
*
|
|
80
|
+
* The requesting device already sees the plaintext `userCode` in its own
|
|
81
|
+
* challenge response ({@link RemoteWebPairingChallengeResponse.userCode});
|
|
82
|
+
* the loopback-gated list route is the only host-side re-exposure. Displaying
|
|
83
|
+
* it there is what lets the approver match the code against the requesting
|
|
84
|
+
* device's screen: the device-flow anti-phishing binding.
|
|
85
|
+
*/
|
|
86
|
+
export interface RemoteWebPairingRequestSummary {
|
|
87
|
+
/** Opaque server-side id used to approve or deny this request. */
|
|
88
|
+
requestId: string;
|
|
89
|
+
/** The human-readable code the requesting device is displaying (e.g. "ABCD-EFGH"). */
|
|
90
|
+
userCode: string;
|
|
91
|
+
/** Public base URL the challenge was minted for. */
|
|
92
|
+
publicBaseUrl: string;
|
|
93
|
+
/** ISO-8601 instant the challenge was minted. */
|
|
94
|
+
requestedAt: string;
|
|
95
|
+
/** ISO-8601 instant the challenge expires. */
|
|
96
|
+
expiresAt: string;
|
|
97
|
+
/**
|
|
98
|
+
* Client IP of the mint request: the loopback/host address when minted
|
|
99
|
+
* locally, or the edge-observed client address when the mint arrived
|
|
100
|
+
* through the nginx tunnel edge (which stamps it via `proxy_set_header`,
|
|
101
|
+
* so a remote client cannot smuggle a value).
|
|
102
|
+
*/
|
|
103
|
+
requesterIp: string;
|
|
104
|
+
/** User-Agent header of the mint request, or null when absent. */
|
|
105
|
+
requesterUserAgent: string | null;
|
|
106
|
+
/**
|
|
107
|
+
* Whether the mint arrived through the public tunnel edge rather than the
|
|
108
|
+
* host itself.
|
|
109
|
+
*/
|
|
110
|
+
viaEdgeProxy: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** `GET /v1/remote-web/pairing-requests` success response body (200). */
|
|
114
|
+
export interface RemoteWebPairingRequestListResponse {
|
|
115
|
+
requests: RemoteWebPairingRequestSummary[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Request body for the pairing-request approve and deny routes. The approve
|
|
120
|
+
* route's success body reuses {@link RemoteWebPairingVerificationResponse}.
|
|
121
|
+
*/
|
|
122
|
+
export interface RemoteWebPairingRequestActionRequest {
|
|
123
|
+
requestId: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** `POST /v1/remote-web/pairing-requests/deny` success response body (200). */
|
|
127
|
+
export interface RemoteWebPairingRequestDenyResponse {
|
|
128
|
+
status: "denied";
|
|
129
|
+
}
|
|
130
|
+
|
|
71
131
|
/** `POST /v1/remote-web/pairing-token` request body. */
|
|
72
132
|
export interface RemoteWebPairingTokenRequest {
|
|
73
133
|
/** The `deviceCode` from the challenge. */
|