@vellumai/assistant 0.11.9-staging.2 → 0.11.9

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 (34) hide show
  1. package/Dockerfile +9 -0
  2. package/docker-bun-no-autoserve.js +38 -0
  3. package/docker-node-launcher.sh +37 -0
  4. package/node_modules/@vellumai/avatar-catalog/src/colors.ts +8 -0
  5. package/node_modules/@vellumai/avatar-manifest/package.json +2 -1
  6. package/node_modules/@vellumai/avatar-manifest/src/__tests__/accent.test.ts +118 -0
  7. package/node_modules/@vellumai/avatar-manifest/src/__tests__/manifest.test.ts +83 -4
  8. package/node_modules/@vellumai/avatar-manifest/src/accent.ts +150 -0
  9. package/node_modules/@vellumai/avatar-manifest/src/index.ts +7 -0
  10. package/node_modules/@vellumai/avatar-manifest/src/manifest.ts +55 -3
  11. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +40 -1
  12. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +21 -0
  13. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +40 -1
  14. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +21 -0
  15. package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +40 -1
  16. package/node_modules/@vellumai/service-contracts/src/channels.ts +21 -0
  17. package/openapi.yaml +116 -1
  18. package/package.json +1 -1
  19. package/src/__tests__/bun-no-autoserve.test.ts +178 -0
  20. package/src/avatar/__tests__/avatar-manifest.test.ts +10 -0
  21. package/src/avatar/__tests__/avatar-store.test.ts +130 -8
  22. package/src/avatar/__tests__/ensure-raster.test.ts +15 -0
  23. package/src/avatar/avatar-accent.ts +57 -0
  24. package/src/avatar/avatar-manifest.ts +20 -2
  25. package/src/avatar/avatar-store.ts +111 -4
  26. package/src/config/bundled-skills/media-processing/SKILL.md +3 -3
  27. package/src/config/bundled-skills/media-processing/TOOLS.json +1 -1
  28. package/src/config/bundled-skills/schedule/SKILL.md +1 -1
  29. package/src/platform/sync-avatar.test.ts +3 -0
  30. package/src/runtime/routes/__tests__/avatar-state-routes.test.ts +147 -27
  31. package/src/runtime/routes/avatar-routes.ts +84 -28
  32. package/src/runtime/routes/settings-routes.ts +1 -1
  33. package/src/tools/terminal/__tests__/safe-env.test.ts +60 -1
  34. package/src/tools/terminal/safe-env.ts +70 -1
@@ -18,6 +18,12 @@ import type { RouteHandlerArgs } from "../types.js";
18
18
 
19
19
  const VALID_TRAITS = { bodyShape: "round", eyeStyle: "happy", color: "blue" };
20
20
 
21
+ /** A 4x4 PNG of one red (#c81e1e), so an accent can be read out of it. */
22
+ const RED_PNG = Buffer.from(
23
+ "iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEklEQVQImWM4ISf3HxkzkC4AAEG4IDHG8wOiAAAAAElFTkSuQmCC",
24
+ "base64",
25
+ );
26
+
21
27
  const IMAGE_FILENAME = "avatar-image.png";
22
28
  const TRAITS_FILENAME = "character-traits.json";
23
29
  const ASCII_FILENAME = "character-ascii.txt";
@@ -75,6 +81,7 @@ describe("GET /avatar/state", () => {
75
81
  traits: VALID_TRAITS,
76
82
  source: "builder",
77
83
  image: null,
84
+ accent: null,
78
85
  };
79
86
  writeManifest(state, avatarDir);
80
87
 
@@ -130,12 +137,9 @@ describe("GET /avatar/state", () => {
130
137
 
131
138
  chmodSync(avatarDir, 0o555);
132
139
  try {
133
- let result: AvatarState | undefined;
134
- expect(() => {
135
- result = getStateHandler()({}) as AvatarState;
136
- }).not.toThrow();
137
- expect(result!.kind).toBe("character");
138
- expect(result!.traits).toEqual(VALID_TRAITS);
140
+ const result = await getStateHandler()({});
141
+ expect(result.kind).toBe("character");
142
+ expect(result.traits).toEqual(VALID_TRAITS);
139
143
  // Persist failed, so no manifest was written.
140
144
  expect(existsSync(join(avatarDir, MANIFEST_FILENAME))).toBe(false);
141
145
  } finally {
@@ -145,21 +149,70 @@ describe("GET /avatar/state", () => {
145
149
  });
146
150
 
147
151
  test("returns kind:none WITHOUT persisting a manifest for an empty workspace (no throw, no 404)", async () => {
148
- let result: AvatarState | undefined;
149
- expect(() => {
150
- result = getStateHandler()({}) as AvatarState;
151
- }).not.toThrow();
152
+ const result = await getStateHandler()({});
152
153
  expect(result).toEqual({
153
154
  kind: "none",
154
155
  traits: null,
155
156
  source: null,
156
157
  image: null,
158
+ accent: null,
157
159
  });
158
160
 
159
161
  // `none` is deliberately NOT persisted — the workspace stays manifest-less
160
162
  // so a later legacy sidecar write is still picked up by the next self-heal.
161
163
  expect(existsSync(join(avatarDir, MANIFEST_FILENAME))).toBe(false);
162
164
  });
165
+
166
+ test("backfills the accent of an image manifest written before accents existed, and persists it", async () => {
167
+ writeFileSync(join(avatarDir, "avatar-image.png"), RED_PNG);
168
+ const state: AvatarState = {
169
+ kind: "image",
170
+ traits: null,
171
+ source: "upload",
172
+ image: { updatedAt: "2026-01-01T00:00:00.000Z", etag: "red-etag-1" },
173
+ accent: null,
174
+ };
175
+ writeManifest(state, avatarDir);
176
+
177
+ const result = await getStateHandler()({});
178
+ expect(result.accent).toEqual({ hex: "#c81e1e", source: "derived" });
179
+ const persisted = JSON.parse(
180
+ readFileSync(join(avatarDir, MANIFEST_FILENAME), "utf-8"),
181
+ ) as AvatarState;
182
+ expect(persisted).toEqual(result);
183
+ });
184
+
185
+ test("backfills a character's palette accent", async () => {
186
+ const state: AvatarState = {
187
+ kind: "character",
188
+ traits: { ...VALID_TRAITS, color: "orange" },
189
+ source: "builder",
190
+ image: null,
191
+ accent: null,
192
+ };
193
+ writeManifest(state, avatarDir);
194
+
195
+ const result = await getStateHandler()({});
196
+ expect(result.accent).toEqual({ hex: "#E9642F", source: "palette" });
197
+ });
198
+
199
+ test("leaves the accent null for an image that cannot be decoded, without failing the read", async () => {
200
+ writeFileSync(
201
+ join(avatarDir, "avatar-image.png"),
202
+ Buffer.from("not a png"),
203
+ );
204
+ const state: AvatarState = {
205
+ kind: "image",
206
+ traits: null,
207
+ source: "upload",
208
+ image: { updatedAt: "2026-01-01T00:00:00.000Z", etag: "bad-etag-1" },
209
+ accent: null,
210
+ };
211
+ writeManifest(state, avatarDir);
212
+
213
+ const result = await getStateHandler()({});
214
+ expect(result).toEqual(state);
215
+ });
163
216
  });
164
217
 
165
218
  /**
@@ -197,6 +250,7 @@ describe("avatar write/remove handlers", () => {
197
250
  traits: Record<string, unknown> | null;
198
251
  source: string | null;
199
252
  image: { updatedAt: string; etag: string } | null;
253
+ accent: { hex: string; source: string } | null;
200
254
  }
201
255
 
202
256
  const readManifestFile = (): ManifestShape | null => {
@@ -273,11 +327,11 @@ describe("avatar write/remove handlers", () => {
273
327
  expect(manifest!.image!.etag).toMatch(/^[0-9a-f]{16}$/);
274
328
  });
275
329
 
276
- test("rejects an imagePath outside the workspace", () => {
330
+ test("rejects an imagePath outside the workspace", async () => {
277
331
  const handler = getHandler("avatar_set");
278
- expect(() => handler({ body: { imagePath: "/etc/passwd" } })).toThrow(
279
- /must resolve inside the workspace/,
280
- );
332
+ await expect(
333
+ handler({ body: { imagePath: "/etc/passwd" } }),
334
+ ).rejects.toThrow(/must resolve inside the workspace/);
281
335
  });
282
336
  });
283
337
 
@@ -310,6 +364,17 @@ describe("avatar write/remove handlers", () => {
310
364
  expect(manifest!.traits).toBeNull();
311
365
  expect(manifest!.source).toBe("upload");
312
366
  expect(manifest!.image!.etag).toMatch(/^[0-9a-f]{16}$/);
367
+ // A signature with no pixels behind it has no colour to read.
368
+ expect(manifest!.accent).toBeNull();
369
+ });
370
+
371
+ test("reads the accent out of the uploaded image", async () => {
372
+ const handler = getHandler("avatar_upload_image");
373
+ await handler({ body: { content: RED_PNG.toString("base64") } });
374
+ expect(readManifestFile()!.accent).toEqual({
375
+ hex: "#c81e1e",
376
+ source: "derived",
377
+ });
313
378
  });
314
379
 
315
380
  test("accepts a base64 payload without an explicit encoding field", async () => {
@@ -321,41 +386,41 @@ describe("avatar write/remove handlers", () => {
321
386
  expect(readManifestFile()!.kind).toBe("image");
322
387
  });
323
388
 
324
- test("rejects a missing content field with 400 and writes no manifest", () => {
389
+ test("rejects a missing content field with 400 and writes no manifest", async () => {
325
390
  const handler = getHandler("avatar_upload_image");
326
- expect(() => handler({ body: {} })).toThrow(/content/);
391
+ await expect(handler({ body: {} })).rejects.toThrow(/content/);
327
392
  expect(readManifestFile()).toBeNull();
328
393
  });
329
394
 
330
- test("rejects a non-base64 / non-image payload with 400", () => {
395
+ test("rejects a non-base64 / non-image payload with 400", async () => {
331
396
  const handler = getHandler("avatar_upload_image");
332
397
  // Valid base64 but decodes to plain text — not a supported image.
333
- expect(() =>
398
+ await expect(
334
399
  handler({
335
400
  body: { content: Buffer.from("not an image").toString("base64") },
336
401
  }),
337
- ).toThrow(/PNG|JPEG|GIF|WEBP|image/);
402
+ ).rejects.toThrow(/PNG|JPEG|GIF|WEBP|image/);
338
403
  expect(readManifestFile()).toBeNull();
339
404
  });
340
405
 
341
- test("rejects an unsupported encoding with 400", () => {
406
+ test("rejects an unsupported encoding with 400", async () => {
342
407
  const handler = getHandler("avatar_upload_image");
343
- expect(() =>
408
+ await expect(
344
409
  handler({
345
410
  body: { content: PNG_BYTES.toString("base64"), encoding: "hex" },
346
411
  }),
347
- ).toThrow(/encoding/);
412
+ ).rejects.toThrow(/encoding/);
348
413
  expect(readManifestFile()).toBeNull();
349
414
  });
350
415
 
351
- test("rejects malformed base64 (valid image prefix + illegal chars) with 400", () => {
416
+ test("rejects malformed base64 (valid image prefix + illegal chars) with 400", async () => {
352
417
  const handler = getHandler("avatar_upload_image");
353
418
  // A valid PNG prefix followed by characters outside the base64 alphabet.
354
419
  // Without strict validation, Buffer.from(.., "base64") would silently
355
420
  // drop the illegal suffix and decode a truncated-but-PNG-magic buffer,
356
421
  // accepting a corrupt avatar. Strict validation must reject it up front.
357
422
  const malformed = `${PNG_BYTES.toString("base64")}!!!@@@***`;
358
- expect(() => handler({ body: { content: malformed } })).toThrow(
423
+ await expect(handler({ body: { content: malformed } })).rejects.toThrow(
359
424
  /valid base64/,
360
425
  );
361
426
  expect(existsSync(path(IMAGE_FILENAME))).toBe(false);
@@ -363,6 +428,56 @@ describe("avatar write/remove handlers", () => {
363
428
  });
364
429
  });
365
430
 
431
+ describe("POST /avatar/accent", () => {
432
+ const uploadRed = async () => {
433
+ await getHandler("avatar_upload_image")({
434
+ body: { content: RED_PNG.toString("base64") },
435
+ });
436
+ };
437
+
438
+ test("sets a custom accent over an image and returns the state as written", async () => {
439
+ await uploadRed();
440
+ const result = (await getHandler("avatar_set_accent")({
441
+ body: { hex: " #12AB34 " },
442
+ })) as AvatarState;
443
+ expect(result.kind).toBe("image");
444
+ expect(result.accent).toEqual({ hex: "#12ab34", source: "custom" });
445
+ expect(readManifestFile()!.accent).toEqual({
446
+ hex: "#12ab34",
447
+ source: "custom",
448
+ });
449
+ // Only the manifest changes; the image is untouched.
450
+ expect(readFileSync(path(IMAGE_FILENAME))).toEqual(RED_PNG);
451
+ });
452
+
453
+ test("null hands the accent back to the colour read out of the image", async () => {
454
+ await uploadRed();
455
+ await getHandler("avatar_set_accent")({ body: { hex: "#12ab34" } });
456
+ const result = (await getHandler("avatar_set_accent")({
457
+ body: { hex: null },
458
+ })) as AvatarState;
459
+ expect(result.accent).toEqual({ hex: "#c81e1e", source: "derived" });
460
+ });
461
+
462
+ test("rejects a hex that is not #rrggbb and leaves the manifest alone", async () => {
463
+ await uploadRed();
464
+ await expect(
465
+ getHandler("avatar_set_accent")({ body: { hex: "red" } }),
466
+ ).rejects.toThrow(/#rrggbb/);
467
+ expect(readManifestFile()!.accent).toEqual({
468
+ hex: "#c81e1e",
469
+ source: "derived",
470
+ });
471
+ });
472
+
473
+ test("rejects when there is no avatar to colour", async () => {
474
+ await expect(
475
+ getHandler("avatar_set_accent")({ body: { hex: "#12ab34" } }),
476
+ ).rejects.toThrow(/No avatar/);
477
+ expect(readManifestFile()).toBeNull();
478
+ });
479
+ });
480
+
366
481
  describe("POST /avatar/remove", () => {
367
482
  test("clears everything to kind:none (revert-to-character branch gone)", async () => {
368
483
  // Seed an image plus legacy character artifacts.
@@ -385,7 +500,7 @@ describe("avatar write/remove handlers", () => {
385
500
  // none == absence: the manifest is deleted, not written as kind:none.
386
501
  expect(readManifestFile()).toBeNull();
387
502
  // A subsequent read still derives kind:none for the empty workspace.
388
- expect((getStateHandler()({}) as AvatarState).kind).toBe("none");
503
+ expect((await getStateHandler()({})).kind).toBe("none");
389
504
  });
390
505
 
391
506
  test("reports hadAvatar:true for a character-only workspace (traits, no PNG)", async () => {
@@ -397,6 +512,7 @@ describe("avatar write/remove handlers", () => {
397
512
  traits: VALID_TRAITS,
398
513
  source: "builder",
399
514
  image: null,
515
+ accent: null,
400
516
  };
401
517
  writeManifest(state, avatarDir);
402
518
  writeFileSync(path(TRAITS_FILENAME), JSON.stringify(VALID_TRAITS));
@@ -412,7 +528,7 @@ describe("avatar write/remove handlers", () => {
412
528
  expect(existsSync(path(TRAITS_FILENAME))).toBe(false);
413
529
  // none == absence: the manifest is deleted, and a read derives none.
414
530
  expect(readManifestFile()).toBeNull();
415
- expect((getStateHandler()({}) as AvatarState).kind).toBe("none");
531
+ expect((await getStateHandler()({})).kind).toBe("none");
416
532
  });
417
533
 
418
534
  test("reports hadAvatar:false and leaves no manifest when nothing exists", async () => {
@@ -471,6 +587,7 @@ describe("GET /avatar/get (manifest-driven precedence)", () => {
471
587
  traits: null,
472
588
  source: "upload",
473
589
  image: { updatedAt: new Date().toISOString(), etag: "deadbeefdeadbeef" },
590
+ accent: null,
474
591
  };
475
592
  writeManifest(state, avatarDir);
476
593
 
@@ -486,6 +603,7 @@ describe("GET /avatar/get (manifest-driven precedence)", () => {
486
603
  traits: null,
487
604
  source: "upload",
488
605
  image: { updatedAt: new Date().toISOString(), etag: "deadbeefdeadbeef" },
606
+ accent: null,
489
607
  };
490
608
  writeManifest(state, avatarDir);
491
609
 
@@ -506,6 +624,7 @@ describe("GET /avatar/get (manifest-driven precedence)", () => {
506
624
  traits: VALID_TRAITS,
507
625
  source: "builder",
508
626
  image: null,
627
+ accent: null,
509
628
  };
510
629
  writeManifest(state, avatarDir);
511
630
 
@@ -524,6 +643,7 @@ describe("GET /avatar/get (manifest-driven precedence)", () => {
524
643
  traits: VALID_TRAITS,
525
644
  source: "builder",
526
645
  image: null,
646
+ accent: null,
527
647
  };
528
648
  writeManifest(state, avatarDir);
529
649
 
@@ -534,7 +654,7 @@ describe("GET /avatar/get (manifest-driven precedence)", () => {
534
654
 
535
655
  test("returns exists:false for a none manifest", async () => {
536
656
  writeManifest(
537
- { kind: "none", traits: null, source: null, image: null },
657
+ { kind: "none", traits: null, source: null, image: null, accent: null },
538
658
  avatarDir,
539
659
  );
540
660
 
@@ -2,7 +2,10 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
 
4
4
  import { getCharacterComponents } from "@vellumai/avatar-catalog";
5
- import { AVATAR_TRAITS_FILENAME } from "@vellumai/avatar-manifest";
5
+ import {
6
+ AVATAR_TRAITS_FILENAME,
7
+ normalizeAvatarAccentHex,
8
+ } from "@vellumai/avatar-manifest";
6
9
  import { z } from "zod";
7
10
 
8
11
  import { renderCharacterAscii } from "../../avatar/ascii-renderer.js";
@@ -13,7 +16,9 @@ import {
13
16
  writeManifest,
14
17
  } from "../../avatar/avatar-manifest.js";
15
18
  import {
19
+ backfillAccent,
16
20
  clearAvatar,
21
+ setAccent,
17
22
  setCharacter,
18
23
  setImage,
19
24
  } from "../../avatar/avatar-store.js";
@@ -81,12 +86,38 @@ function readManifestSelfHealing(): AvatarState {
81
86
  * Return the authoritative avatar render state.
82
87
  *
83
88
  * Reads the manifest (`avatar.json`). When the manifest is absent it is
84
- * self-healed once from the legacy sidecar files and persisted. Never 404s —
85
- * an empty workspace yields `{ kind: "none", traits: null, source: null,
86
- * image: null }`.
89
+ * self-healed once from the legacy sidecar files and persisted, and a manifest
90
+ * written before accents existed has its accent filled in the same way. Never
91
+ * 404s: an empty workspace yields `{ kind: "none" }` with every other field
92
+ * null.
87
93
  */
88
94
  function handleGetAvatarState() {
89
- return readManifestSelfHealing();
95
+ return backfillAccent(readManifestSelfHealing());
96
+ }
97
+
98
+ /**
99
+ * Set the accent over the current avatar, or hand it back to the automatic
100
+ * one. Returns the state as written so the caller can paint without a
101
+ * second read.
102
+ */
103
+ async function handleSetAvatarAccent({ body, headers }: RouteHandlerArgs) {
104
+ const payload = body as Record<string, unknown> | undefined;
105
+ const raw = payload?.hex;
106
+ let hex: string | null = null;
107
+ if (raw !== null && raw !== undefined) {
108
+ hex = normalizeAvatarAccentHex(raw);
109
+ if (!hex) {
110
+ throw new BadRequestError("hex must be a #rrggbb colour, or null");
111
+ }
112
+ }
113
+
114
+ const state = await setAccent(hex);
115
+ if (!state) {
116
+ throw new BadRequestError("No avatar to set an accent on");
117
+ }
118
+
119
+ publishAvatarChanged(headers?.["x-vellum-client-id"]?.trim() || undefined);
120
+ return state;
90
121
  }
91
122
 
92
123
  function handleRenderFromTraits({ body, headers }: RouteHandlerArgs) {
@@ -147,7 +178,7 @@ async function handleGenerateAvatar({ body, headers }: RouteHandlerArgs) {
147
178
 
148
179
  // Route through the store: atomically writes the PNG, removes the now-stale
149
180
  // character sidecars (traits + ASCII), and records an AI-sourced manifest.
150
- setImage(result.pngBuffer, "ai");
181
+ await setImage(result.pngBuffer, "ai");
151
182
 
152
183
  publishAvatarChanged(headers?.["x-vellum-client-id"]?.trim() || undefined);
153
184
  return { ok: true, message: result.content };
@@ -159,7 +190,7 @@ async function handleGenerateAvatar({ body, headers }: RouteHandlerArgs) {
159
190
  * single server-authoritative endpoint: the store atomically writes the PNG,
160
191
  * clears the character sidecars, and records an `image` manifest.
161
192
  */
162
- function handleUploadAvatarImage({ body, headers }: RouteHandlerArgs) {
193
+ async function handleUploadAvatarImage({ body, headers }: RouteHandlerArgs) {
163
194
  const payload = body as Record<string, unknown> | undefined;
164
195
  const content = payload?.content;
165
196
  const encoding = payload?.encoding;
@@ -195,13 +226,13 @@ function handleUploadAvatarImage({ body, headers }: RouteHandlerArgs) {
195
226
 
196
227
  // Route through the store: atomically writes the PNG, removes the now-stale
197
228
  // character sidecars (traits + ASCII), and records an uploaded-image manifest.
198
- setImage(buffer, "upload");
229
+ await setImage(buffer, "upload");
199
230
 
200
231
  publishAvatarChanged(headers?.["x-vellum-client-id"]?.trim() || undefined);
201
232
  return { ok: true };
202
233
  }
203
234
 
204
- function handleSetAvatar({ body, headers }: RouteHandlerArgs) {
235
+ async function handleSetAvatar({ body, headers }: RouteHandlerArgs) {
205
236
  const imagePath = (body as Record<string, unknown>)?.imagePath as
206
237
  | string
207
238
  | undefined;
@@ -227,7 +258,7 @@ function handleSetAvatar({ body, headers }: RouteHandlerArgs) {
227
258
 
228
259
  // Route through the store so traits sidecars are cleared and the manifest is
229
260
  // recorded as an uploaded image atomically (no more stale both-files state).
230
- setImage(readFileSync(normalized), "upload");
261
+ await setImage(readFileSync(normalized), "upload");
231
262
 
232
263
  publishAvatarChanged(headers?.["x-vellum-client-id"]?.trim() || undefined);
233
264
  return { ok: true };
@@ -333,6 +364,31 @@ function handleCharacterAscii({ queryParams, body }: RouteHandlerArgs) {
333
364
  return { ascii };
334
365
  }
335
366
 
367
+ /** The wire shape of `avatar.json`, shared by every route that answers with it. */
368
+ const avatarStateSchema = z.object({
369
+ kind: z.enum(["character", "image", "none"]),
370
+ traits: z
371
+ .object({
372
+ bodyShape: z.string(),
373
+ eyeStyle: z.string(),
374
+ color: z.string(),
375
+ })
376
+ .nullable(),
377
+ source: z.enum(["builder", "upload", "ai"]).nullable(),
378
+ image: z
379
+ .object({
380
+ updatedAt: z.string(),
381
+ etag: z.string(),
382
+ })
383
+ .nullable(),
384
+ accent: z
385
+ .object({
386
+ hex: z.string(),
387
+ source: z.enum(["palette", "derived", "custom"]),
388
+ })
389
+ .nullable(),
390
+ });
391
+
336
392
  export const ROUTES: RouteDefinition[] = [
337
393
  {
338
394
  operationId: "avatar_character_components",
@@ -384,25 +440,25 @@ export const ROUTES: RouteDefinition[] = [
384
440
  handler: handleGetAvatarState,
385
441
  summary: "Get avatar state",
386
442
  description:
387
- "Return the authoritative avatar render mode (character, image, or none).",
443
+ "Return the authoritative avatar render mode (character, image, or none) and its accent colour.",
388
444
  tags: ["avatar"],
389
- responseBody: z.object({
390
- kind: z.enum(["character", "image", "none"]),
391
- traits: z
392
- .object({
393
- bodyShape: z.string(),
394
- eyeStyle: z.string(),
395
- color: z.string(),
396
- })
397
- .nullable(),
398
- source: z.enum(["builder", "upload", "ai"]).nullable(),
399
- image: z
400
- .object({
401
- updatedAt: z.string(),
402
- etag: z.string(),
403
- })
404
- .nullable(),
405
- }),
445
+ responseBody: avatarStateSchema,
446
+ },
447
+ {
448
+ operationId: "avatar_set_accent",
449
+ endpoint: "avatar/accent",
450
+ method: "POST",
451
+ policy: {
452
+ requiredScopes: ["settings.write"],
453
+ allowedPrincipalTypes: ACTOR_PRINCIPALS,
454
+ },
455
+ handler: handleSetAvatarAccent,
456
+ summary: "Set avatar accent",
457
+ description:
458
+ "Set the accent colour over the current avatar as #rrggbb, or null to return to the automatic one (the character's palette colour, or the colour read out of the uploaded image).",
459
+ tags: ["avatar"],
460
+ requestBody: z.object({ hex: z.string().nullable() }),
461
+ responseBody: avatarStateSchema,
406
462
  },
407
463
  {
408
464
  operationId: "avatar_render_from_traits",
@@ -117,7 +117,7 @@ async function handleGenerateAvatar({ body = {}, headers }: RouteHandlerArgs) {
117
117
 
118
118
  // Route through the store so traits sidecars are cleared and the manifest
119
119
  // is recorded as an AI-sourced image atomically.
120
- setImage(result.pngBuffer, "ai");
120
+ await setImage(result.pngBuffer, "ai");
121
121
 
122
122
  const avatarPath = getAvatarImagePath();
123
123
 
@@ -1,4 +1,14 @@
1
- import { afterEach, describe, expect, test } from "bun:test";
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import {
5
+ afterAll,
6
+ afterEach,
7
+ beforeAll,
8
+ describe,
9
+ expect,
10
+ test,
11
+ } from "bun:test";
2
12
 
3
13
  import {
4
14
  buildSanitizedEnv,
@@ -98,3 +108,52 @@ describe("safe-env Windows forwarding", () => {
98
108
  expect(windowsEnv.COMSPEC).toBe("C:\\Windows\\System32\\cmd.exe");
99
109
  });
100
110
  });
111
+
112
+ describe("safe-env PATH scrubbing", () => {
113
+ let root: string;
114
+ let shimDir: string;
115
+ let realNodeDir: string;
116
+ let emptyDir: string;
117
+
118
+ beforeAll(() => {
119
+ // Directly under a temp root, so it matches the shape Bun synthesizes.
120
+ shimDir = mkdtempSync(join(tmpdir(), "bun-node-"));
121
+ root = mkdtempSync(join(tmpdir(), "safe-env-path-"));
122
+ realNodeDir = join(root, "real-bin");
123
+ emptyDir = join(root, "empty-bin");
124
+ mkdirSync(realNodeDir);
125
+ mkdirSync(emptyDir);
126
+ writeFileSync(join(realNodeDir, "node"), "", { mode: 0o755 });
127
+ });
128
+
129
+ afterAll(() => {
130
+ rmSync(shimDir, { recursive: true, force: true });
131
+ rmSync(root, { recursive: true, force: true });
132
+ });
133
+
134
+ test("drops Bun's synthesized node shim dir, keeping the rest in order", () => {
135
+ const env = buildSanitizedEnv("linux", {
136
+ PATH: `${shimDir}:${realNodeDir}:/usr/bin:/bin`,
137
+ });
138
+
139
+ expect(env.PATH).toBe(`${realNodeDir}:/usr/bin:/bin`);
140
+ });
141
+
142
+ test("keeps a similarly named directory outside the temp dir", () => {
143
+ const env = buildSanitizedEnv("linux", {
144
+ PATH: `${realNodeDir}:/home/assistant/bun-node-tools:/usr/bin`,
145
+ });
146
+
147
+ expect(env.PATH).toBe(
148
+ `${realNodeDir}:/home/assistant/bun-node-tools:/usr/bin`,
149
+ );
150
+ });
151
+
152
+ test("keeps the shim when it is the only node on PATH", () => {
153
+ const env = buildSanitizedEnv("linux", {
154
+ PATH: `${shimDir}:${emptyDir}`,
155
+ });
156
+
157
+ expect(env.PATH).toBe(`${shimDir}:${emptyDir}`);
158
+ });
159
+ });
@@ -5,7 +5,8 @@
5
5
  *
6
6
  * Shared by the sandbox bash tool and skill sandbox runner.
7
7
  */
8
- import { readdirSync } from "node:fs";
8
+ import { readdirSync, statSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
9
10
 
10
11
  import { pathListDelimiter } from "@vellumai/environments/shell";
11
12
 
@@ -169,6 +170,69 @@ function kataPythonPaths(dataRoot: string): string[] {
169
170
  ];
170
171
  }
171
172
 
173
+ // Bun synthesizes a `node` shim directory (<temp>/bun-node-<hash>/, holding
174
+ // `node` and `bun` symlinks to itself) and prepends it to PATH whenever it
175
+ // starts with no real Node on PATH. That `node` is Bun, which runs a Node CLI
176
+ // under different semantics, so it must never shadow a real interpreter in a
177
+ // sandbox subprocess. It stays when it is the only `node` there is: on a native
178
+ // install (Bun only, no Node) removing it would break every `#!/usr/bin/env
179
+ // node` CLI outright.
180
+ function bunNodeShimParents(sourceEnv: NodeJS.ProcessEnv): string[] {
181
+ const roots = [
182
+ tmpdir(),
183
+ "/tmp",
184
+ sourceEnv.TMPDIR,
185
+ sourceEnv.TEMP,
186
+ sourceEnv.TMP,
187
+ ];
188
+ return roots
189
+ .filter((root): root is string => Boolean(root))
190
+ .map((root) => root.replace(/[\\/]+$/, ""));
191
+ }
192
+
193
+ function hasNodeExecutable(dir: string): boolean {
194
+ for (const name of ["node", "node.exe"]) {
195
+ try {
196
+ if (statSync(`${dir}/${name}`).isFile()) {
197
+ return true;
198
+ }
199
+ } catch {
200
+ // Entry missing or unreadable: not a Node interpreter we can use.
201
+ }
202
+ }
203
+ return false;
204
+ }
205
+
206
+ function stripBunNodeShimDirs(
207
+ value: string,
208
+ sourceEnv: NodeJS.ProcessEnv,
209
+ ): string {
210
+ const parents = bunNodeShimParents(sourceEnv);
211
+ const separator = pathListDelimiter();
212
+ const entries = value.split(separator);
213
+ const kept = entries.filter((entry) => {
214
+ const normalized = entry.replace(/[\\/]+$/, "");
215
+ const cut = Math.max(
216
+ normalized.lastIndexOf("/"),
217
+ normalized.lastIndexOf("\\"),
218
+ );
219
+ if (cut < 0) {
220
+ return true;
221
+ }
222
+ return !(
223
+ normalized.slice(cut + 1).startsWith("bun-node-") &&
224
+ parents.includes(normalized.slice(0, cut))
225
+ );
226
+ });
227
+ if (kept.length === entries.length) {
228
+ return value;
229
+ }
230
+ if (!kept.some(hasNodeExecutable)) {
231
+ return value;
232
+ }
233
+ return kept.join(separator);
234
+ }
235
+
172
236
  /**
173
237
  * Keys that buildSanitizedEnv always injects into the returned env,
174
238
  * independent of what is present in process.env.
@@ -245,6 +309,11 @@ export function buildSanitizedEnv(
245
309
  );
246
310
  }
247
311
  }
312
+ // Runs after the kata entries are in place: a Node installed into the
313
+ // persistent apt chroot is what makes dropping Bun's shim safe there.
314
+ if (env.PATH != null) {
315
+ env.PATH = stripBunNodeShimDirs(env.PATH, sourceEnv);
316
+ }
248
317
  // Always inject an internal gateway base for local control-plane/API calls.
249
318
  const internalGatewayBase = getGatewayInternalBaseUrl();
250
319
  env.INTERNAL_GATEWAY_BASE_URL = internalGatewayBase;