@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.
- package/Dockerfile +9 -0
- package/docker-bun-no-autoserve.js +38 -0
- package/docker-node-launcher.sh +37 -0
- package/node_modules/@vellumai/avatar-catalog/src/colors.ts +8 -0
- package/node_modules/@vellumai/avatar-manifest/package.json +2 -1
- package/node_modules/@vellumai/avatar-manifest/src/__tests__/accent.test.ts +118 -0
- package/node_modules/@vellumai/avatar-manifest/src/__tests__/manifest.test.ts +83 -4
- package/node_modules/@vellumai/avatar-manifest/src/accent.ts +150 -0
- package/node_modules/@vellumai/avatar-manifest/src/index.ts +7 -0
- package/node_modules/@vellumai/avatar-manifest/src/manifest.ts +55 -3
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +40 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +21 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +40 -1
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +21 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +40 -1
- package/node_modules/@vellumai/service-contracts/src/channels.ts +21 -0
- package/openapi.yaml +116 -1
- package/package.json +1 -1
- package/src/__tests__/bun-no-autoserve.test.ts +178 -0
- package/src/avatar/__tests__/avatar-manifest.test.ts +10 -0
- package/src/avatar/__tests__/avatar-store.test.ts +130 -8
- package/src/avatar/__tests__/ensure-raster.test.ts +15 -0
- package/src/avatar/avatar-accent.ts +57 -0
- package/src/avatar/avatar-manifest.ts +20 -2
- package/src/avatar/avatar-store.ts +111 -4
- package/src/config/bundled-skills/media-processing/SKILL.md +3 -3
- package/src/config/bundled-skills/media-processing/TOOLS.json +1 -1
- package/src/config/bundled-skills/schedule/SKILL.md +1 -1
- package/src/platform/sync-avatar.test.ts +3 -0
- package/src/runtime/routes/__tests__/avatar-state-routes.test.ts +147 -27
- package/src/runtime/routes/avatar-routes.ts +84 -28
- package/src/runtime/routes/settings-routes.ts +1 -1
- package/src/tools/terminal/__tests__/safe-env.test.ts +60 -1
- 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
|
-
|
|
134
|
-
expect(()
|
|
135
|
-
|
|
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
|
-
|
|
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(
|
|
279
|
-
|
|
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(
|
|
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(
|
|
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()({})
|
|
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()({})
|
|
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 {
|
|
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
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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:
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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 {
|
|
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;
|