@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.
Files changed (41) hide show
  1. package/node_modules/@vellumai/local-mode/src/__tests__/unpair.test.ts +33 -0
  2. package/node_modules/@vellumai/local-mode/src/index.ts +3 -0
  3. package/node_modules/@vellumai/local-mode/src/lockfile-lock.test.ts +165 -0
  4. package/node_modules/@vellumai/local-mode/src/lockfile-lock.ts +156 -0
  5. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +249 -8
  6. package/node_modules/@vellumai/local-mode/src/lockfile.ts +186 -68
  7. package/node_modules/@vellumai/local-mode/src/unpair.ts +18 -0
  8. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  9. package/node_modules/@vellumai/service-contracts/src/__tests__/url-normalization.test.ts +135 -0
  10. package/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  11. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  12. package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
  13. package/node_modules/@vellumai/service-contracts/src/url-normalization.ts +107 -0
  14. package/package.json +1 -1
  15. package/src/__tests__/assistant-config.test.ts +35 -0
  16. package/src/__tests__/nginx-ingress-command.test.ts +4 -23
  17. package/src/__tests__/nginx-ingress.test.ts +59 -215
  18. package/src/__tests__/pair.test.ts +11 -197
  19. package/src/__tests__/retire-archive.test.ts +13 -1
  20. package/src/__tests__/retire-local.test.ts +58 -4
  21. package/src/__tests__/tunnel.test.ts +0 -28
  22. package/src/__tests__/wake.test.ts +91 -69
  23. package/src/__tests__/windows-lifecycle.test.ts +157 -0
  24. package/src/commands/client.ts +9 -31
  25. package/src/commands/nginx-ingress.ts +0 -15
  26. package/src/commands/pair.ts +0 -39
  27. package/src/commands/wake.ts +39 -12
  28. package/src/lib/__tests__/web-dist.test.ts +86 -0
  29. package/src/lib/assistant-config.ts +64 -27
  30. package/src/lib/local.ts +60 -20
  31. package/src/lib/nginx-ingress.ts +35 -108
  32. package/src/lib/orphan-detection.test.ts +3 -0
  33. package/src/lib/orphan-detection.ts +33 -11
  34. package/src/lib/pgrep.ts +20 -2
  35. package/src/lib/process.ts +191 -18
  36. package/src/lib/retire-archive.ts +38 -8
  37. package/src/lib/retire-local.ts +75 -9
  38. package/src/lib/tunnel-edge.ts +14 -22
  39. package/src/lib/web-dist.ts +48 -0
  40. package/src/lib/feature-flags.test.ts +0 -157
  41. 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
- | { ok: true; data: Lockfile }
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 { ok: false, status: 500, error: `Failed to write lockfile: ${err}` };
157
+ return {
158
+ ok: false,
159
+ status: 500,
160
+ error: `Failed to write lockfile: ${err}`,
161
+ };
103
162
  }
104
163
 
105
- const stripped = JSON.parse(JSON.stringify(lockfile)) as Record<
106
- string,
107
- unknown
108
- >;
109
- stripSensitiveFields(stripped);
110
- return { ok: true, lockfile: parseLockfile(stripped) };
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 lockfile = readRawLockfile(lockfilePaths);
123
- const assistants = Array.isArray(lockfile.assistants) ? lockfile.assistants : [];
124
- const existingIdx = assistants.findIndex(
125
- (a: Record<string, unknown>) => a?.assistantId === assistant.assistantId,
126
- );
127
- if (existingIdx >= 0) {
128
- assistants[existingIdx] = { ...assistants[existingIdx], ...assistant };
129
- } else {
130
- assistants.push(assistant);
131
- }
132
- lockfile.assistants = assistants;
133
- if (activeAssistant !== undefined) {
134
- lockfile.activeAssistant = activeAssistant;
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
- return writeRawLockfile(lockfilePaths, lockfile);
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
- const lockfile = readRawLockfile(lockfilePaths);
158
- const assistants = Array.isArray(lockfile.assistants)
159
- ? (lockfile.assistants as Array<Record<string, unknown>>)
160
- : [];
161
- const existing = assistants.find(
162
- (entry) => entry?.assistantId === assistant.assistantId,
163
- );
164
- const merged = { ...existing, ...assistant };
165
- const existingIsPaired =
166
- existing != null &&
167
- (resolveCloud(existing) === "paired" || existing.paired === true);
168
- const mergedIsPaired =
169
- resolveCloud(merged) === "paired" || merged.paired === true;
170
-
171
- if (!existingIsPaired && mergedIsPaired) {
172
- return { ok: false, status: 403, error: PAIRED_LOCKFILE_WRITE_ERROR };
173
- }
174
- if (
175
- existingIsPaired &&
176
- (resolveCloud(merged) !== "paired" ||
177
- merged.runtimeUrl !== existing.runtimeUrl ||
178
- merged.paired !== existing.paired)
179
- ) {
180
- return { ok: false, status: 403, error: PAIRED_LOCKFILE_WRITE_ERROR };
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
- return upsertLockfileAssistant(lockfilePaths, assistant, activeAssistant);
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 lockfile = readRawLockfile(lockfilePaths);
230
- const existing = Array.isArray(lockfile.assistants) ? lockfile.assistants : [];
231
- const syncedIds = new Set(platformAssistants.map((a) => a.assistantId));
232
- // Org-scoped sync preserves other orgs' platform entries; no org full-replaces.
233
- const preserved = existing.filter((a: Record<string, unknown>) => {
234
- if (a?.cloud !== "vellum") return true;
235
- if (syncedIds.has(a.assistantId)) return false;
236
- return organizationId != null && a.organizationId !== organizationId;
237
- });
238
- lockfile.assistants = [...preserved, ...platformAssistants];
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
- const active = lockfile.activeAssistant as string | null;
241
- if (active) {
242
- const stillExists = (lockfile.assistants as Array<Record<string, unknown>>).some(
243
- (a) => a.assistantId === active,
244
- );
245
- if (!stillExists) lockfile.activeAssistant = null;
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
- return writeRawLockfile(lockfilePaths, lockfile);
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];
@@ -29,3 +29,4 @@ export * from "./trust-rules.js";
29
29
  export * from "./ingress.js";
30
30
  export * from "./remote-web-pairing.js";
31
31
  export * from "./twilio-ingress.js";
32
+ export * from "./url-normalization.js";
@@ -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. */