@vellumai/cli 0.12.4 → 0.12.5-dev.202609250721.20229d0
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/avatar-manifest/src/__tests__/notification-avatar.test.ts +48 -4
- package/node_modules/@vellumai/avatar-manifest/src/notification-avatar.ts +12 -8
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/avatar-manifest/src/__tests__/notification-avatar.test.ts +48 -4
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/avatar-manifest/src/notification-avatar.ts +12 -8
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/__tests__/guardian-requests.test.ts +26 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +77 -3
- package/node_modules/@vellumai/service-contracts/src/__tests__/guardian-requests.test.ts +26 -0
- package/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +77 -3
- package/package.json +1 -1
- package/src/__tests__/debug-bundle.test.ts +257 -0
- package/src/commands/debug-bundle.ts +221 -0
- package/src/index.ts +5 -0
- package/src/lib/local-runtime-client.ts +12 -1
- package/src/shared/provider-env-vars.ts +1 -0
|
@@ -67,8 +67,31 @@ describe("notificationAvatarDiscHex", () => {
|
|
|
67
67
|
});
|
|
68
68
|
|
|
69
69
|
describe("notificationAvatarSvg", () => {
|
|
70
|
+
test("fills the entire circular crop with a custom image", () => {
|
|
71
|
+
const svg = notificationAvatarSvg({
|
|
72
|
+
kind: "image",
|
|
73
|
+
innerPngBase64: PNG_BASE64,
|
|
74
|
+
accentHex: "#E9642F",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(attributes("image", svg)).toMatchObject({
|
|
78
|
+
x: "0",
|
|
79
|
+
y: "0",
|
|
80
|
+
width: "256",
|
|
81
|
+
height: "256",
|
|
82
|
+
preserveAspectRatio: "xMidYMid slice",
|
|
83
|
+
"clip-path": "url(#notification-avatar-disc)",
|
|
84
|
+
});
|
|
85
|
+
expect(allAttributes("circle", svg)[1]).toEqual({
|
|
86
|
+
cx: "128",
|
|
87
|
+
cy: "128",
|
|
88
|
+
r: "128",
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
70
92
|
test("draws one disc and one inset avatar at the default size", () => {
|
|
71
93
|
const svg = notificationAvatarSvg({
|
|
94
|
+
kind: "character",
|
|
72
95
|
innerPngBase64: PNG_BASE64,
|
|
73
96
|
accentHex: "#E9642F",
|
|
74
97
|
});
|
|
@@ -100,6 +123,7 @@ describe("notificationAvatarSvg", () => {
|
|
|
100
123
|
// upload fill the square instead of letterboxing is the pair of attributes
|
|
101
124
|
// asserted here.
|
|
102
125
|
const svg = notificationAvatarSvg({
|
|
126
|
+
kind: "character",
|
|
103
127
|
innerPngBase64: PNG_BASE64,
|
|
104
128
|
accentHex: "#E9642F",
|
|
105
129
|
});
|
|
@@ -121,6 +145,7 @@ describe("notificationAvatarSvg", () => {
|
|
|
121
145
|
|
|
122
146
|
test("scales the geometry to a custom size", () => {
|
|
123
147
|
const svg = notificationAvatarSvg({
|
|
148
|
+
kind: "character",
|
|
124
149
|
innerPngBase64: PNG_BASE64,
|
|
125
150
|
accentHex: "#E9642F",
|
|
126
151
|
size: 100,
|
|
@@ -142,6 +167,7 @@ describe("notificationAvatarSvg", () => {
|
|
|
142
167
|
|
|
143
168
|
test("carries a non-PNG inner raster with its own media type", () => {
|
|
144
169
|
const svg = notificationAvatarSvg({
|
|
170
|
+
kind: "character",
|
|
145
171
|
innerPngBase64: PNG_BASE64,
|
|
146
172
|
innerMediaType: "image/jpeg",
|
|
147
173
|
accentHex: "#E9642F",
|
|
@@ -154,6 +180,7 @@ describe("notificationAvatarSvg", () => {
|
|
|
154
180
|
|
|
155
181
|
test("paints the fallback disc when there is no accent", () => {
|
|
156
182
|
const svg = notificationAvatarSvg({
|
|
183
|
+
kind: "character",
|
|
157
184
|
innerPngBase64: PNG_BASE64,
|
|
158
185
|
accentHex: null,
|
|
159
186
|
});
|
|
@@ -164,8 +191,21 @@ describe("notificationAvatarSvg", () => {
|
|
|
164
191
|
});
|
|
165
192
|
|
|
166
193
|
describe("notificationAvatarGeometry", () => {
|
|
194
|
+
test("custom images reach the disc edge at any output size", () => {
|
|
195
|
+
expect(notificationAvatarGeometry("image")).toEqual({
|
|
196
|
+
radius: 128,
|
|
197
|
+
offset: 0,
|
|
198
|
+
inner: 256,
|
|
199
|
+
});
|
|
200
|
+
expect(notificationAvatarGeometry("image", 100)).toEqual({
|
|
201
|
+
radius: 50,
|
|
202
|
+
offset: 0,
|
|
203
|
+
inner: 100,
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
167
207
|
test("derives the disc, the border and the avatar edge from the size", () => {
|
|
168
|
-
expect(notificationAvatarGeometry(100)).toEqual({
|
|
208
|
+
expect(notificationAvatarGeometry("character", 100)).toEqual({
|
|
169
209
|
radius: 50,
|
|
170
210
|
offset: 11,
|
|
171
211
|
inner: 78,
|
|
@@ -173,15 +213,19 @@ describe("notificationAvatarGeometry", () => {
|
|
|
173
213
|
});
|
|
174
214
|
|
|
175
215
|
test("measures the default size when given none", () => {
|
|
176
|
-
expect(notificationAvatarGeometry()).toEqual(
|
|
177
|
-
notificationAvatarGeometry(NOTIFICATION_AVATAR_SIZE),
|
|
216
|
+
expect(notificationAvatarGeometry("character")).toEqual(
|
|
217
|
+
notificationAvatarGeometry("character", NOTIFICATION_AVATAR_SIZE),
|
|
178
218
|
);
|
|
179
219
|
});
|
|
180
220
|
|
|
181
221
|
test("is the geometry the SVG is drawn with", () => {
|
|
182
222
|
const size = 100;
|
|
183
|
-
const { radius, offset, inner } = notificationAvatarGeometry(
|
|
223
|
+
const { radius, offset, inner } = notificationAvatarGeometry(
|
|
224
|
+
"character",
|
|
225
|
+
size,
|
|
226
|
+
);
|
|
184
227
|
const svg = notificationAvatarSvg({
|
|
228
|
+
kind: "character",
|
|
185
229
|
innerPngBase64: PNG_BASE64,
|
|
186
230
|
accentHex: null,
|
|
187
231
|
size,
|
|
@@ -15,11 +15,12 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { isAvatarAccentHex } from "./accent.js";
|
|
18
|
+
import type { AvatarKind } from "./manifest.js";
|
|
18
19
|
|
|
19
20
|
/** Output edge in pixels: large enough for an iOS notification thumbnail. */
|
|
20
21
|
export const NOTIFICATION_AVATAR_SIZE = 256;
|
|
21
22
|
|
|
22
|
-
/** Free disc on each side, as a fraction of the edge
|
|
23
|
+
/** Free disc on each side of a character avatar, as a fraction of the edge. */
|
|
23
24
|
export const NOTIFICATION_AVATAR_INSET = 0.11;
|
|
24
25
|
|
|
25
26
|
/** The disc when the assistant has no accent. */
|
|
@@ -48,7 +49,7 @@ export const NOTIFICATION_AVATAR_MAX_LOCAL_BYTES = 512 * 1024;
|
|
|
48
49
|
* Bumped whenever the drawing above changes, so a sync keyed on it re-uploads
|
|
49
50
|
* a disc rendered by an older spec even when the avatar itself is unchanged.
|
|
50
51
|
*/
|
|
51
|
-
export const NOTIFICATION_AVATAR_SPEC_VERSION =
|
|
52
|
+
export const NOTIFICATION_AVATAR_SPEC_VERSION = 2;
|
|
52
53
|
|
|
53
54
|
/** The id the inner raster's clip path is referenced by inside the document. */
|
|
54
55
|
const CLIP_ID = "notification-avatar-disc";
|
|
@@ -74,24 +75,26 @@ export function notificationAvatarDiscHex(accentHex: string | null): string {
|
|
|
74
75
|
|
|
75
76
|
/** Raster formats an `<image>` href carries here; what resvg and a canvas both decode. */
|
|
76
77
|
export type NotificationAvatarMediaType =
|
|
77
|
-
| "image/
|
|
78
|
-
| "image/jpeg"
|
|
79
|
-
| "image/gif";
|
|
78
|
+
"image/png" | "image/jpeg" | "image/gif";
|
|
80
79
|
|
|
81
80
|
/**
|
|
82
81
|
* The three measurements the drawing derives from the edge: the disc's radius,
|
|
83
82
|
* which is also its centre; the free border around the avatar; and the
|
|
84
83
|
* avatar's own edge. Every rasterizer reads them from here, so the daemon's
|
|
85
84
|
* SVG and the desktop canvas cannot drift.
|
|
85
|
+
* Custom images fill the disc; character avatars retain space around their
|
|
86
|
+
* silhouette.
|
|
86
87
|
*/
|
|
87
88
|
export function notificationAvatarGeometry(
|
|
89
|
+
kind: Exclude<AvatarKind, "none">,
|
|
88
90
|
size: number = NOTIFICATION_AVATAR_SIZE,
|
|
89
91
|
): { radius: number; offset: number; inner: number } {
|
|
90
|
-
const offset = size * NOTIFICATION_AVATAR_INSET;
|
|
92
|
+
const offset = kind === "character" ? size * NOTIFICATION_AVATAR_INSET : 0;
|
|
91
93
|
return { radius: size / 2, offset, inner: size - 2 * offset };
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
export interface NotificationAvatarSvgOptions {
|
|
97
|
+
kind: Exclude<AvatarKind, "none">;
|
|
95
98
|
/** The avatar raster to draw inside the disc, base64 with no data prefix. */
|
|
96
99
|
innerPngBase64: string;
|
|
97
100
|
/** What `innerPngBase64` holds; PNG unless an upload arrived as a JPEG or a GIF. */
|
|
@@ -107,7 +110,7 @@ function px(value: number): string {
|
|
|
107
110
|
|
|
108
111
|
/**
|
|
109
112
|
* The notification avatar as an SVG document: a filled disc with the avatar
|
|
110
|
-
*
|
|
113
|
+
* filling it for custom images or inset for character avatars.
|
|
111
114
|
*
|
|
112
115
|
* The inner raster is cover-cropped (`xMidYMid slice`), matching what the web
|
|
113
116
|
* canvas does, so a portrait or landscape upload fills the square instead of
|
|
@@ -119,12 +122,13 @@ function px(value: number): string {
|
|
|
119
122
|
* rasterizer is known to render it.
|
|
120
123
|
*/
|
|
121
124
|
export function notificationAvatarSvg({
|
|
125
|
+
kind,
|
|
122
126
|
innerPngBase64,
|
|
123
127
|
innerMediaType = "image/png",
|
|
124
128
|
accentHex,
|
|
125
129
|
size = NOTIFICATION_AVATAR_SIZE,
|
|
126
130
|
}: NotificationAvatarSvgOptions): string {
|
|
127
|
-
const { radius, offset, inner } = notificationAvatarGeometry(size);
|
|
131
|
+
const { radius, offset, inner } = notificationAvatarGeometry(kind, size);
|
|
128
132
|
const href = `data:${innerMediaType};base64,${innerPngBase64}`;
|
|
129
133
|
const disc = `cx="${px(radius)}" cy="${px(radius)}" r="${px(radius)}"`;
|
|
130
134
|
return (
|
|
@@ -67,8 +67,31 @@ describe("notificationAvatarDiscHex", () => {
|
|
|
67
67
|
});
|
|
68
68
|
|
|
69
69
|
describe("notificationAvatarSvg", () => {
|
|
70
|
+
test("fills the entire circular crop with a custom image", () => {
|
|
71
|
+
const svg = notificationAvatarSvg({
|
|
72
|
+
kind: "image",
|
|
73
|
+
innerPngBase64: PNG_BASE64,
|
|
74
|
+
accentHex: "#E9642F",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(attributes("image", svg)).toMatchObject({
|
|
78
|
+
x: "0",
|
|
79
|
+
y: "0",
|
|
80
|
+
width: "256",
|
|
81
|
+
height: "256",
|
|
82
|
+
preserveAspectRatio: "xMidYMid slice",
|
|
83
|
+
"clip-path": "url(#notification-avatar-disc)",
|
|
84
|
+
});
|
|
85
|
+
expect(allAttributes("circle", svg)[1]).toEqual({
|
|
86
|
+
cx: "128",
|
|
87
|
+
cy: "128",
|
|
88
|
+
r: "128",
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
70
92
|
test("draws one disc and one inset avatar at the default size", () => {
|
|
71
93
|
const svg = notificationAvatarSvg({
|
|
94
|
+
kind: "character",
|
|
72
95
|
innerPngBase64: PNG_BASE64,
|
|
73
96
|
accentHex: "#E9642F",
|
|
74
97
|
});
|
|
@@ -100,6 +123,7 @@ describe("notificationAvatarSvg", () => {
|
|
|
100
123
|
// upload fill the square instead of letterboxing is the pair of attributes
|
|
101
124
|
// asserted here.
|
|
102
125
|
const svg = notificationAvatarSvg({
|
|
126
|
+
kind: "character",
|
|
103
127
|
innerPngBase64: PNG_BASE64,
|
|
104
128
|
accentHex: "#E9642F",
|
|
105
129
|
});
|
|
@@ -121,6 +145,7 @@ describe("notificationAvatarSvg", () => {
|
|
|
121
145
|
|
|
122
146
|
test("scales the geometry to a custom size", () => {
|
|
123
147
|
const svg = notificationAvatarSvg({
|
|
148
|
+
kind: "character",
|
|
124
149
|
innerPngBase64: PNG_BASE64,
|
|
125
150
|
accentHex: "#E9642F",
|
|
126
151
|
size: 100,
|
|
@@ -142,6 +167,7 @@ describe("notificationAvatarSvg", () => {
|
|
|
142
167
|
|
|
143
168
|
test("carries a non-PNG inner raster with its own media type", () => {
|
|
144
169
|
const svg = notificationAvatarSvg({
|
|
170
|
+
kind: "character",
|
|
145
171
|
innerPngBase64: PNG_BASE64,
|
|
146
172
|
innerMediaType: "image/jpeg",
|
|
147
173
|
accentHex: "#E9642F",
|
|
@@ -154,6 +180,7 @@ describe("notificationAvatarSvg", () => {
|
|
|
154
180
|
|
|
155
181
|
test("paints the fallback disc when there is no accent", () => {
|
|
156
182
|
const svg = notificationAvatarSvg({
|
|
183
|
+
kind: "character",
|
|
157
184
|
innerPngBase64: PNG_BASE64,
|
|
158
185
|
accentHex: null,
|
|
159
186
|
});
|
|
@@ -164,8 +191,21 @@ describe("notificationAvatarSvg", () => {
|
|
|
164
191
|
});
|
|
165
192
|
|
|
166
193
|
describe("notificationAvatarGeometry", () => {
|
|
194
|
+
test("custom images reach the disc edge at any output size", () => {
|
|
195
|
+
expect(notificationAvatarGeometry("image")).toEqual({
|
|
196
|
+
radius: 128,
|
|
197
|
+
offset: 0,
|
|
198
|
+
inner: 256,
|
|
199
|
+
});
|
|
200
|
+
expect(notificationAvatarGeometry("image", 100)).toEqual({
|
|
201
|
+
radius: 50,
|
|
202
|
+
offset: 0,
|
|
203
|
+
inner: 100,
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
167
207
|
test("derives the disc, the border and the avatar edge from the size", () => {
|
|
168
|
-
expect(notificationAvatarGeometry(100)).toEqual({
|
|
208
|
+
expect(notificationAvatarGeometry("character", 100)).toEqual({
|
|
169
209
|
radius: 50,
|
|
170
210
|
offset: 11,
|
|
171
211
|
inner: 78,
|
|
@@ -173,15 +213,19 @@ describe("notificationAvatarGeometry", () => {
|
|
|
173
213
|
});
|
|
174
214
|
|
|
175
215
|
test("measures the default size when given none", () => {
|
|
176
|
-
expect(notificationAvatarGeometry()).toEqual(
|
|
177
|
-
notificationAvatarGeometry(NOTIFICATION_AVATAR_SIZE),
|
|
216
|
+
expect(notificationAvatarGeometry("character")).toEqual(
|
|
217
|
+
notificationAvatarGeometry("character", NOTIFICATION_AVATAR_SIZE),
|
|
178
218
|
);
|
|
179
219
|
});
|
|
180
220
|
|
|
181
221
|
test("is the geometry the SVG is drawn with", () => {
|
|
182
222
|
const size = 100;
|
|
183
|
-
const { radius, offset, inner } = notificationAvatarGeometry(
|
|
223
|
+
const { radius, offset, inner } = notificationAvatarGeometry(
|
|
224
|
+
"character",
|
|
225
|
+
size,
|
|
226
|
+
);
|
|
184
227
|
const svg = notificationAvatarSvg({
|
|
228
|
+
kind: "character",
|
|
185
229
|
innerPngBase64: PNG_BASE64,
|
|
186
230
|
accentHex: null,
|
|
187
231
|
size,
|
|
@@ -15,11 +15,12 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { isAvatarAccentHex } from "./accent.js";
|
|
18
|
+
import type { AvatarKind } from "./manifest.js";
|
|
18
19
|
|
|
19
20
|
/** Output edge in pixels: large enough for an iOS notification thumbnail. */
|
|
20
21
|
export const NOTIFICATION_AVATAR_SIZE = 256;
|
|
21
22
|
|
|
22
|
-
/** Free disc on each side, as a fraction of the edge
|
|
23
|
+
/** Free disc on each side of a character avatar, as a fraction of the edge. */
|
|
23
24
|
export const NOTIFICATION_AVATAR_INSET = 0.11;
|
|
24
25
|
|
|
25
26
|
/** The disc when the assistant has no accent. */
|
|
@@ -48,7 +49,7 @@ export const NOTIFICATION_AVATAR_MAX_LOCAL_BYTES = 512 * 1024;
|
|
|
48
49
|
* Bumped whenever the drawing above changes, so a sync keyed on it re-uploads
|
|
49
50
|
* a disc rendered by an older spec even when the avatar itself is unchanged.
|
|
50
51
|
*/
|
|
51
|
-
export const NOTIFICATION_AVATAR_SPEC_VERSION =
|
|
52
|
+
export const NOTIFICATION_AVATAR_SPEC_VERSION = 2;
|
|
52
53
|
|
|
53
54
|
/** The id the inner raster's clip path is referenced by inside the document. */
|
|
54
55
|
const CLIP_ID = "notification-avatar-disc";
|
|
@@ -74,24 +75,26 @@ export function notificationAvatarDiscHex(accentHex: string | null): string {
|
|
|
74
75
|
|
|
75
76
|
/** Raster formats an `<image>` href carries here; what resvg and a canvas both decode. */
|
|
76
77
|
export type NotificationAvatarMediaType =
|
|
77
|
-
| "image/
|
|
78
|
-
| "image/jpeg"
|
|
79
|
-
| "image/gif";
|
|
78
|
+
"image/png" | "image/jpeg" | "image/gif";
|
|
80
79
|
|
|
81
80
|
/**
|
|
82
81
|
* The three measurements the drawing derives from the edge: the disc's radius,
|
|
83
82
|
* which is also its centre; the free border around the avatar; and the
|
|
84
83
|
* avatar's own edge. Every rasterizer reads them from here, so the daemon's
|
|
85
84
|
* SVG and the desktop canvas cannot drift.
|
|
85
|
+
* Custom images fill the disc; character avatars retain space around their
|
|
86
|
+
* silhouette.
|
|
86
87
|
*/
|
|
87
88
|
export function notificationAvatarGeometry(
|
|
89
|
+
kind: Exclude<AvatarKind, "none">,
|
|
88
90
|
size: number = NOTIFICATION_AVATAR_SIZE,
|
|
89
91
|
): { radius: number; offset: number; inner: number } {
|
|
90
|
-
const offset = size * NOTIFICATION_AVATAR_INSET;
|
|
92
|
+
const offset = kind === "character" ? size * NOTIFICATION_AVATAR_INSET : 0;
|
|
91
93
|
return { radius: size / 2, offset, inner: size - 2 * offset };
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
export interface NotificationAvatarSvgOptions {
|
|
97
|
+
kind: Exclude<AvatarKind, "none">;
|
|
95
98
|
/** The avatar raster to draw inside the disc, base64 with no data prefix. */
|
|
96
99
|
innerPngBase64: string;
|
|
97
100
|
/** What `innerPngBase64` holds; PNG unless an upload arrived as a JPEG or a GIF. */
|
|
@@ -107,7 +110,7 @@ function px(value: number): string {
|
|
|
107
110
|
|
|
108
111
|
/**
|
|
109
112
|
* The notification avatar as an SVG document: a filled disc with the avatar
|
|
110
|
-
*
|
|
113
|
+
* filling it for custom images or inset for character avatars.
|
|
111
114
|
*
|
|
112
115
|
* The inner raster is cover-cropped (`xMidYMid slice`), matching what the web
|
|
113
116
|
* canvas does, so a portrait or landscape upload fills the square instead of
|
|
@@ -119,12 +122,13 @@ function px(value: number): string {
|
|
|
119
122
|
* rasterizer is known to render it.
|
|
120
123
|
*/
|
|
121
124
|
export function notificationAvatarSvg({
|
|
125
|
+
kind,
|
|
122
126
|
innerPngBase64,
|
|
123
127
|
innerMediaType = "image/png",
|
|
124
128
|
accentHex,
|
|
125
129
|
size = NOTIFICATION_AVATAR_SIZE,
|
|
126
130
|
}: NotificationAvatarSvgOptions): string {
|
|
127
|
-
const { radius, offset, inner } = notificationAvatarGeometry(size);
|
|
131
|
+
const { radius, offset, inner } = notificationAvatarGeometry(kind, size);
|
|
128
132
|
const href = `data:${innerMediaType};base64,${innerPngBase64}`;
|
|
129
133
|
const disc = `cx="${px(radius)}" cy="${px(radius)}" r="${px(radius)}"`;
|
|
130
134
|
return (
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
GUARDIAN_DECISION_ACTION_IDS,
|
|
5
|
+
isDenyingGuardianAction,
|
|
6
|
+
isParkGuardianAction,
|
|
7
|
+
} from "../guardian-requests.js";
|
|
8
|
+
|
|
9
|
+
describe("guardian decision actions", () => {
|
|
10
|
+
test("reject, leave_unverified and block deny; every other action approves", () => {
|
|
11
|
+
const denying = GUARDIAN_DECISION_ACTION_IDS.filter(
|
|
12
|
+
isDenyingGuardianAction,
|
|
13
|
+
);
|
|
14
|
+
expect(denying).toEqual(["reject", "leave_unverified", "block"]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("only leave_unverified parks the sender", () => {
|
|
18
|
+
const parking = GUARDIAN_DECISION_ACTION_IDS.filter(isParkGuardianAction);
|
|
19
|
+
expect(parking).toEqual(["leave_unverified"]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("an absent or unknown action neither denies nor parks", () => {
|
|
23
|
+
expect(isDenyingGuardianAction(undefined)).toBe(false);
|
|
24
|
+
expect(isParkGuardianAction("approve_always")).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Canonical vocabulary for
|
|
3
|
-
* the
|
|
4
|
-
*
|
|
2
|
+
* Canonical vocabulary for guardian requests, shared by the gateway that owns
|
|
3
|
+
* the request rows, the daemon that decides and projects them, and the web
|
|
4
|
+
* that renders the projection: a request's lifecycle status, the actions a
|
|
5
|
+
* guardian can decide it with, and the weight a card gives each action.
|
|
5
6
|
*/
|
|
6
7
|
import { z } from "zod";
|
|
7
8
|
|
|
@@ -16,3 +17,76 @@ export const GuardianRequestStatusSchema = z.enum(
|
|
|
16
17
|
GUARDIAN_REQUEST_STATUS_VALUES,
|
|
17
18
|
);
|
|
18
19
|
export type GuardianRequestStatus = z.infer<typeof GuardianRequestStatusSchema>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The actions a guardian can decide a request with.
|
|
23
|
+
*
|
|
24
|
+
* `approve_once` / `reject` are the generic decision pair used by every
|
|
25
|
+
* request kind. `trust` / `verify_code` / `leave_unverified` / `block` are the
|
|
26
|
+
* introduction-card actions, valid only for `access_request` requests.
|
|
27
|
+
*/
|
|
28
|
+
export const GUARDIAN_DECISION_ACTION_IDS = [
|
|
29
|
+
"approve_once",
|
|
30
|
+
"reject",
|
|
31
|
+
"trust",
|
|
32
|
+
"verify_code",
|
|
33
|
+
"leave_unverified",
|
|
34
|
+
"block",
|
|
35
|
+
] as const;
|
|
36
|
+
export const GuardianDecisionActionIdSchema = z.enum(
|
|
37
|
+
GUARDIAN_DECISION_ACTION_IDS,
|
|
38
|
+
);
|
|
39
|
+
export type GuardianDecisionActionId = z.infer<
|
|
40
|
+
typeof GuardianDecisionActionIdSchema
|
|
41
|
+
>;
|
|
42
|
+
|
|
43
|
+
/** Actions that resolve a request to `denied`; every other one approves. */
|
|
44
|
+
export const GUARDIAN_DENYING_ACTION_VALUES = [
|
|
45
|
+
"reject",
|
|
46
|
+
"leave_unverified",
|
|
47
|
+
"block",
|
|
48
|
+
] as const satisfies readonly GuardianDecisionActionId[];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The denying actions that park the sender at `unverified`: a neutral hold,
|
|
52
|
+
* not a rejection. A parked contact is still admitted under the permissive
|
|
53
|
+
* admission floors (`any_contact`, `strangers`) and can be trusted or verified
|
|
54
|
+
* later; contrast `block` (revoked, a hard keep-out) and `reject` (an explicit
|
|
55
|
+
* decline). All three resolve the request to `denied`, so a resolved card
|
|
56
|
+
* consults this to read a park neutrally rather than as a denial.
|
|
57
|
+
*/
|
|
58
|
+
export const GUARDIAN_PARK_ACTION_VALUES = [
|
|
59
|
+
"leave_unverified",
|
|
60
|
+
] as const satisfies readonly GuardianDecisionActionId[];
|
|
61
|
+
|
|
62
|
+
const DENYING_ACTIONS: ReadonlySet<string> = new Set(
|
|
63
|
+
GUARDIAN_DENYING_ACTION_VALUES,
|
|
64
|
+
);
|
|
65
|
+
const PARK_ACTIONS: ReadonlySet<string> = new Set(GUARDIAN_PARK_ACTION_VALUES);
|
|
66
|
+
|
|
67
|
+
/** True when `action` resolves a request to `denied`. */
|
|
68
|
+
export function isDenyingGuardianAction(action: string | undefined): boolean {
|
|
69
|
+
return action !== undefined && DENYING_ACTIONS.has(action);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** True when `action` parks the sender at `unverified`. */
|
|
73
|
+
export function isParkGuardianAction(action: string | undefined): boolean {
|
|
74
|
+
return action !== undefined && PARK_ACTIONS.has(action);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Surface-agnostic weight of a card action. Each renderer translates it to
|
|
79
|
+
* its own token (Slack primary/danger, a web button variant); absent means the
|
|
80
|
+
* renderer's default.
|
|
81
|
+
*/
|
|
82
|
+
export const GUARDIAN_ACTION_EMPHASIS_VALUES = [
|
|
83
|
+
"primary",
|
|
84
|
+
"secondary",
|
|
85
|
+
"destructive",
|
|
86
|
+
] as const;
|
|
87
|
+
export const GuardianActionEmphasisSchema = z.enum(
|
|
88
|
+
GUARDIAN_ACTION_EMPHASIS_VALUES,
|
|
89
|
+
);
|
|
90
|
+
export type GuardianActionEmphasis = z.infer<
|
|
91
|
+
typeof GuardianActionEmphasisSchema
|
|
92
|
+
>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
GUARDIAN_DECISION_ACTION_IDS,
|
|
5
|
+
isDenyingGuardianAction,
|
|
6
|
+
isParkGuardianAction,
|
|
7
|
+
} from "../guardian-requests.js";
|
|
8
|
+
|
|
9
|
+
describe("guardian decision actions", () => {
|
|
10
|
+
test("reject, leave_unverified and block deny; every other action approves", () => {
|
|
11
|
+
const denying = GUARDIAN_DECISION_ACTION_IDS.filter(
|
|
12
|
+
isDenyingGuardianAction,
|
|
13
|
+
);
|
|
14
|
+
expect(denying).toEqual(["reject", "leave_unverified", "block"]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("only leave_unverified parks the sender", () => {
|
|
18
|
+
const parking = GUARDIAN_DECISION_ACTION_IDS.filter(isParkGuardianAction);
|
|
19
|
+
expect(parking).toEqual(["leave_unverified"]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("an absent or unknown action neither denies nor parks", () => {
|
|
23
|
+
expect(isDenyingGuardianAction(undefined)).toBe(false);
|
|
24
|
+
expect(isParkGuardianAction("approve_always")).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Canonical vocabulary for
|
|
3
|
-
* the
|
|
4
|
-
*
|
|
2
|
+
* Canonical vocabulary for guardian requests, shared by the gateway that owns
|
|
3
|
+
* the request rows, the daemon that decides and projects them, and the web
|
|
4
|
+
* that renders the projection: a request's lifecycle status, the actions a
|
|
5
|
+
* guardian can decide it with, and the weight a card gives each action.
|
|
5
6
|
*/
|
|
6
7
|
import { z } from "zod";
|
|
7
8
|
|
|
@@ -16,3 +17,76 @@ export const GuardianRequestStatusSchema = z.enum(
|
|
|
16
17
|
GUARDIAN_REQUEST_STATUS_VALUES,
|
|
17
18
|
);
|
|
18
19
|
export type GuardianRequestStatus = z.infer<typeof GuardianRequestStatusSchema>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The actions a guardian can decide a request with.
|
|
23
|
+
*
|
|
24
|
+
* `approve_once` / `reject` are the generic decision pair used by every
|
|
25
|
+
* request kind. `trust` / `verify_code` / `leave_unverified` / `block` are the
|
|
26
|
+
* introduction-card actions, valid only for `access_request` requests.
|
|
27
|
+
*/
|
|
28
|
+
export const GUARDIAN_DECISION_ACTION_IDS = [
|
|
29
|
+
"approve_once",
|
|
30
|
+
"reject",
|
|
31
|
+
"trust",
|
|
32
|
+
"verify_code",
|
|
33
|
+
"leave_unverified",
|
|
34
|
+
"block",
|
|
35
|
+
] as const;
|
|
36
|
+
export const GuardianDecisionActionIdSchema = z.enum(
|
|
37
|
+
GUARDIAN_DECISION_ACTION_IDS,
|
|
38
|
+
);
|
|
39
|
+
export type GuardianDecisionActionId = z.infer<
|
|
40
|
+
typeof GuardianDecisionActionIdSchema
|
|
41
|
+
>;
|
|
42
|
+
|
|
43
|
+
/** Actions that resolve a request to `denied`; every other one approves. */
|
|
44
|
+
export const GUARDIAN_DENYING_ACTION_VALUES = [
|
|
45
|
+
"reject",
|
|
46
|
+
"leave_unverified",
|
|
47
|
+
"block",
|
|
48
|
+
] as const satisfies readonly GuardianDecisionActionId[];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The denying actions that park the sender at `unverified`: a neutral hold,
|
|
52
|
+
* not a rejection. A parked contact is still admitted under the permissive
|
|
53
|
+
* admission floors (`any_contact`, `strangers`) and can be trusted or verified
|
|
54
|
+
* later; contrast `block` (revoked, a hard keep-out) and `reject` (an explicit
|
|
55
|
+
* decline). All three resolve the request to `denied`, so a resolved card
|
|
56
|
+
* consults this to read a park neutrally rather than as a denial.
|
|
57
|
+
*/
|
|
58
|
+
export const GUARDIAN_PARK_ACTION_VALUES = [
|
|
59
|
+
"leave_unverified",
|
|
60
|
+
] as const satisfies readonly GuardianDecisionActionId[];
|
|
61
|
+
|
|
62
|
+
const DENYING_ACTIONS: ReadonlySet<string> = new Set(
|
|
63
|
+
GUARDIAN_DENYING_ACTION_VALUES,
|
|
64
|
+
);
|
|
65
|
+
const PARK_ACTIONS: ReadonlySet<string> = new Set(GUARDIAN_PARK_ACTION_VALUES);
|
|
66
|
+
|
|
67
|
+
/** True when `action` resolves a request to `denied`. */
|
|
68
|
+
export function isDenyingGuardianAction(action: string | undefined): boolean {
|
|
69
|
+
return action !== undefined && DENYING_ACTIONS.has(action);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** True when `action` parks the sender at `unverified`. */
|
|
73
|
+
export function isParkGuardianAction(action: string | undefined): boolean {
|
|
74
|
+
return action !== undefined && PARK_ACTIONS.has(action);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Surface-agnostic weight of a card action. Each renderer translates it to
|
|
79
|
+
* its own token (Slack primary/danger, a web button variant); absent means the
|
|
80
|
+
* renderer's default.
|
|
81
|
+
*/
|
|
82
|
+
export const GUARDIAN_ACTION_EMPHASIS_VALUES = [
|
|
83
|
+
"primary",
|
|
84
|
+
"secondary",
|
|
85
|
+
"destructive",
|
|
86
|
+
] as const;
|
|
87
|
+
export const GuardianActionEmphasisSchema = z.enum(
|
|
88
|
+
GUARDIAN_ACTION_EMPHASIS_VALUES,
|
|
89
|
+
);
|
|
90
|
+
export type GuardianActionEmphasis = z.infer<
|
|
91
|
+
typeof GuardianActionEmphasisSchema
|
|
92
|
+
>;
|
package/package.json
CHANGED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
mock,
|
|
8
|
+
spyOn,
|
|
9
|
+
test,
|
|
10
|
+
} from "bun:test";
|
|
11
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
// Lockfile isolation (mirrors backup.test.ts)
|
|
16
|
+
const testDir = mkdtempSync(join(tmpdir(), "cli-debug-bundle-test-"));
|
|
17
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
18
|
+
|
|
19
|
+
import * as assistantConfig from "../lib/assistant-config.js";
|
|
20
|
+
import * as guardianToken from "../lib/guardian-token.js";
|
|
21
|
+
import * as localRuntimeClient from "../lib/local-runtime-client.js";
|
|
22
|
+
import * as platformClient from "../lib/platform-client.js";
|
|
23
|
+
|
|
24
|
+
const resolveTargetMock = spyOn(
|
|
25
|
+
assistantConfig,
|
|
26
|
+
"resolveTargetAssistant",
|
|
27
|
+
).mockImplementation(() => {
|
|
28
|
+
throw new Error("process.exit:1");
|
|
29
|
+
});
|
|
30
|
+
const readPlatformTokenMock = spyOn(
|
|
31
|
+
platformClient,
|
|
32
|
+
"readPlatformToken",
|
|
33
|
+
).mockReturnValue("platform-token");
|
|
34
|
+
const exportMock = spyOn(
|
|
35
|
+
localRuntimeClient,
|
|
36
|
+
"localRuntimeExportToGcs",
|
|
37
|
+
).mockResolvedValue({ jobId: "job-1" });
|
|
38
|
+
const identityMock = spyOn(
|
|
39
|
+
localRuntimeClient,
|
|
40
|
+
"localRuntimeIdentity",
|
|
41
|
+
).mockResolvedValue({ version: "0.12.3" });
|
|
42
|
+
const pollMock = spyOn(
|
|
43
|
+
localRuntimeClient,
|
|
44
|
+
"localRuntimePollJobStatus",
|
|
45
|
+
).mockResolvedValue({
|
|
46
|
+
jobId: "job-1",
|
|
47
|
+
type: "export",
|
|
48
|
+
status: "complete",
|
|
49
|
+
} as unknown as Awaited<
|
|
50
|
+
ReturnType<typeof localRuntimeClient.localRuntimePollJobStatus>
|
|
51
|
+
>);
|
|
52
|
+
const loadGuardianTokenSpy = spyOn(
|
|
53
|
+
guardianToken,
|
|
54
|
+
"loadGuardianToken",
|
|
55
|
+
).mockReturnValue({
|
|
56
|
+
accessToken: "local-token",
|
|
57
|
+
accessTokenExpiresAt: new Date(Date.now() + 10 * 60_000).toISOString(),
|
|
58
|
+
} as unknown as ReturnType<typeof guardianToken.loadGuardianToken>);
|
|
59
|
+
const leaseGuardianTokenSpy = spyOn(
|
|
60
|
+
guardianToken,
|
|
61
|
+
"leaseGuardianToken",
|
|
62
|
+
).mockResolvedValue({
|
|
63
|
+
accessToken: "leased-token",
|
|
64
|
+
accessTokenExpiresAt: new Date(Date.now() + 10 * 60_000).toISOString(),
|
|
65
|
+
} as unknown as Awaited<ReturnType<typeof guardianToken.leaseGuardianToken>>);
|
|
66
|
+
|
|
67
|
+
const { debugBundle } = await import("../commands/debug-bundle.js");
|
|
68
|
+
|
|
69
|
+
const LOCAL_ENTRY = {
|
|
70
|
+
name: "mine",
|
|
71
|
+
assistantId: "local-asst-1",
|
|
72
|
+
runtimeUrl: "http://127.0.0.1:7821",
|
|
73
|
+
cloud: "local",
|
|
74
|
+
species: "local",
|
|
75
|
+
hatchedAt: new Date().toISOString(),
|
|
76
|
+
platformAssistantId: "11111111-2222-3333-4444-555555555555",
|
|
77
|
+
platformBaseUrl: "https://platform.vellum.ai",
|
|
78
|
+
platformOrganizationId: "org-1",
|
|
79
|
+
} as unknown as assistantConfig.AssistantEntry;
|
|
80
|
+
|
|
81
|
+
let originalFetch: typeof globalThis.fetch;
|
|
82
|
+
let originalExit: typeof process.exit;
|
|
83
|
+
let fetchCalls: { url: string; init?: RequestInit }[] = [];
|
|
84
|
+
let uploadUrlStatus = 201;
|
|
85
|
+
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
process.argv = ["bun", "vellum", "debug-bundle"];
|
|
88
|
+
originalFetch = globalThis.fetch;
|
|
89
|
+
originalExit = process.exit;
|
|
90
|
+
process.exit = mock((code?: number) => {
|
|
91
|
+
throw new Error(`process.exit:${code}`);
|
|
92
|
+
}) as unknown as typeof process.exit;
|
|
93
|
+
fetchCalls = [];
|
|
94
|
+
uploadUrlStatus = 201;
|
|
95
|
+
globalThis.fetch = mock(
|
|
96
|
+
async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
97
|
+
fetchCalls.push({ url: String(input), init });
|
|
98
|
+
return new Response(
|
|
99
|
+
JSON.stringify({ url: "https://storage.googleapis.com/b/signed" }),
|
|
100
|
+
{
|
|
101
|
+
status: uploadUrlStatus,
|
|
102
|
+
headers: { "Content-Type": "application/json" },
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
},
|
|
106
|
+
) as unknown as typeof globalThis.fetch;
|
|
107
|
+
resolveTargetMock.mockReset();
|
|
108
|
+
resolveTargetMock.mockReturnValue(LOCAL_ENTRY);
|
|
109
|
+
identityMock.mockReset();
|
|
110
|
+
identityMock.mockResolvedValue({ version: "0.12.3" });
|
|
111
|
+
leaseGuardianTokenSpy.mockClear();
|
|
112
|
+
readPlatformTokenMock.mockReset();
|
|
113
|
+
readPlatformTokenMock.mockReturnValue("platform-token");
|
|
114
|
+
exportMock.mockClear();
|
|
115
|
+
pollMock.mockClear();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
afterEach(() => {
|
|
119
|
+
globalThis.fetch = originalFetch;
|
|
120
|
+
process.exit = originalExit;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
afterAll(() => {
|
|
124
|
+
resolveTargetMock.mockRestore();
|
|
125
|
+
identityMock.mockRestore();
|
|
126
|
+
leaseGuardianTokenSpy.mockRestore();
|
|
127
|
+
readPlatformTokenMock.mockRestore();
|
|
128
|
+
exportMock.mockRestore();
|
|
129
|
+
pollMock.mockRestore();
|
|
130
|
+
loadGuardianTokenSpy.mockRestore();
|
|
131
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("vellum debug-bundle", () => {
|
|
135
|
+
test("mints the URL on the platform, exports with the debug profile, and waits", async () => {
|
|
136
|
+
await debugBundle();
|
|
137
|
+
|
|
138
|
+
expect(fetchCalls).toHaveLength(1);
|
|
139
|
+
expect(fetchCalls[0].url).toBe(
|
|
140
|
+
"https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/debug-bundle-upload-url/",
|
|
141
|
+
);
|
|
142
|
+
expect(fetchCalls[0].init?.method).toBe("POST");
|
|
143
|
+
expect(
|
|
144
|
+
(fetchCalls[0].init?.headers as Record<string, string>)[
|
|
145
|
+
"Vellum-Organization-Id"
|
|
146
|
+
],
|
|
147
|
+
).toBe("org-1");
|
|
148
|
+
|
|
149
|
+
expect(exportMock).toHaveBeenCalledTimes(1);
|
|
150
|
+
const [entry, token, params] = exportMock.mock.calls[0];
|
|
151
|
+
expect(entry).toBe(LOCAL_ENTRY);
|
|
152
|
+
expect(token).toBe("local-token");
|
|
153
|
+
expect(params).toEqual({
|
|
154
|
+
uploadUrl: "https://storage.googleapis.com/b/signed",
|
|
155
|
+
description: "debug bundle for Vellum staff",
|
|
156
|
+
profile: "debug",
|
|
157
|
+
});
|
|
158
|
+
expect(pollMock).toHaveBeenCalled();
|
|
159
|
+
// The daemon was checked for the debug profile before the platform call.
|
|
160
|
+
expect(identityMock).toHaveBeenCalledTimes(1);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("passes an unquoted multi-word display name through to the shared resolver", async () => {
|
|
164
|
+
process.argv = ["bun", "vellum", "debug-bundle", "Support", "Bot"];
|
|
165
|
+
await debugBundle();
|
|
166
|
+
expect(resolveTargetMock).toHaveBeenCalledWith("Support Bot");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("refuses a daemon older than the debug profile before any URL is minted", async () => {
|
|
170
|
+
// Such a daemon strips `profile` and would upload the owner's
|
|
171
|
+
// credentials to the staff bucket.
|
|
172
|
+
identityMock.mockResolvedValue({ version: "0.12.2" });
|
|
173
|
+
const errors: string[] = [];
|
|
174
|
+
const errorSpy = spyOn(console, "error").mockImplementation((msg) => {
|
|
175
|
+
errors.push(String(msg));
|
|
176
|
+
});
|
|
177
|
+
try {
|
|
178
|
+
await expect(debugBundle()).rejects.toThrow("process.exit:1");
|
|
179
|
+
} finally {
|
|
180
|
+
errorSpy.mockRestore();
|
|
181
|
+
}
|
|
182
|
+
expect(errors.join("\n")).toContain("0.12.3");
|
|
183
|
+
expect(fetchCalls).toHaveLength(0);
|
|
184
|
+
expect(exportMock).not.toHaveBeenCalled();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("re-leases the guardian token when the daemon answers 401 mid-poll", async () => {
|
|
188
|
+
pollMock.mockReset();
|
|
189
|
+
let polls = 0;
|
|
190
|
+
pollMock.mockImplementation(async (_entry, token) => {
|
|
191
|
+
polls += 1;
|
|
192
|
+
if (polls === 1) {
|
|
193
|
+
throw new Error("Local job status check failed: 401 Unauthorized");
|
|
194
|
+
}
|
|
195
|
+
expect(token).toBe("leased-token");
|
|
196
|
+
return {
|
|
197
|
+
jobId: "job-1",
|
|
198
|
+
type: "export",
|
|
199
|
+
status: "complete",
|
|
200
|
+
} as unknown as Awaited<
|
|
201
|
+
ReturnType<typeof localRuntimeClient.localRuntimePollJobStatus>
|
|
202
|
+
>;
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
await debugBundle();
|
|
206
|
+
|
|
207
|
+
expect(leaseGuardianTokenSpy).toHaveBeenCalledTimes(1);
|
|
208
|
+
expect(polls).toBe(2);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("explains what to turn on when the platform refuses", async () => {
|
|
212
|
+
uploadUrlStatus = 403;
|
|
213
|
+
const errors: string[] = [];
|
|
214
|
+
const errorSpy = spyOn(console, "error").mockImplementation((msg) => {
|
|
215
|
+
errors.push(String(msg));
|
|
216
|
+
});
|
|
217
|
+
try {
|
|
218
|
+
await expect(debugBundle()).rejects.toThrow("process.exit:1");
|
|
219
|
+
} finally {
|
|
220
|
+
errorSpy.mockRestore();
|
|
221
|
+
}
|
|
222
|
+
expect(errors.join("\n")).toContain("Allow Staff Access");
|
|
223
|
+
expect(exportMock).not.toHaveBeenCalled();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("refuses a Vellum-hosted assistant, which needs no bundle", async () => {
|
|
227
|
+
resolveTargetMock.mockReturnValue({
|
|
228
|
+
...LOCAL_ENTRY,
|
|
229
|
+
cloud: "vellum",
|
|
230
|
+
} as unknown as assistantConfig.AssistantEntry);
|
|
231
|
+
const errorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
232
|
+
try {
|
|
233
|
+
await expect(debugBundle()).rejects.toThrow("process.exit:1");
|
|
234
|
+
} finally {
|
|
235
|
+
errorSpy.mockRestore();
|
|
236
|
+
}
|
|
237
|
+
expect(fetchCalls).toHaveLength(0);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("asks for a login when the assistant is not registered", async () => {
|
|
241
|
+
resolveTargetMock.mockReturnValue({
|
|
242
|
+
...LOCAL_ENTRY,
|
|
243
|
+
platformAssistantId: undefined,
|
|
244
|
+
} as unknown as assistantConfig.AssistantEntry);
|
|
245
|
+
const errors: string[] = [];
|
|
246
|
+
const errorSpy = spyOn(console, "error").mockImplementation((msg) => {
|
|
247
|
+
errors.push(String(msg));
|
|
248
|
+
});
|
|
249
|
+
try {
|
|
250
|
+
await expect(debugBundle()).rejects.toThrow("process.exit:1");
|
|
251
|
+
} finally {
|
|
252
|
+
errorSpy.mockRestore();
|
|
253
|
+
}
|
|
254
|
+
expect(errors.join("\n")).toContain("vellum login");
|
|
255
|
+
expect(fetchCalls).toHaveLength(0);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `vellum debug-bundle [name]`
|
|
3
|
+
*
|
|
4
|
+
* Send Vellum staff a debug bundle of a self-hosted assistant: the
|
|
5
|
+
* workspace, its logs, and the gateway's own database, with no credentials.
|
|
6
|
+
* Staff open it on a throwaway debug clone. The platform only hands out the
|
|
7
|
+
* upload URL while "Allow Staff Access" is on, and the bundle is deleted
|
|
8
|
+
* after seven days.
|
|
9
|
+
*
|
|
10
|
+
* Steps: the daemon is checked for the debug profile, the platform mints
|
|
11
|
+
* the upload URL, the daemon exports with the debug profile, and this
|
|
12
|
+
* command waits for the job so the result is real.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
formatAssistantReference,
|
|
16
|
+
resolveTargetAssistant,
|
|
17
|
+
} from "../lib/assistant-config.js";
|
|
18
|
+
import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
|
|
19
|
+
import { loadGuardianToken, leaseGuardianToken } from "../lib/guardian-token";
|
|
20
|
+
import { pollJobUntilDone } from "../lib/job-polling.js";
|
|
21
|
+
import {
|
|
22
|
+
MigrationInProgressError,
|
|
23
|
+
localRuntimeExportToGcs,
|
|
24
|
+
localRuntimeIdentity,
|
|
25
|
+
localRuntimePollJobStatus,
|
|
26
|
+
} from "../lib/local-runtime-client.js";
|
|
27
|
+
import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
|
|
28
|
+
import {
|
|
29
|
+
authHeaders,
|
|
30
|
+
authHeadersForKnownOrganization,
|
|
31
|
+
getPlatformUrl,
|
|
32
|
+
readPlatformToken,
|
|
33
|
+
} from "../lib/platform-client.js";
|
|
34
|
+
import { compareVersions } from "../lib/version-compat.js";
|
|
35
|
+
|
|
36
|
+
// Matches the daemon's own upload deadline (EXPORT_TO_GCS_PUT_TIMEOUT_MS in
|
|
37
|
+
// assistant/src/runtime/routes/migration-routes.ts), so a slow but healthy
|
|
38
|
+
// export is never reported as failed while it is still uploading.
|
|
39
|
+
const EXPORT_TIMEOUT_MS = 60 * 60 * 1000;
|
|
40
|
+
// The daemon release that added `profile: "debug"`. An older daemon strips
|
|
41
|
+
// the field and exports a normal teleport bundle, which carries the owner's
|
|
42
|
+
// credentials. That must never reach the staff bucket.
|
|
43
|
+
export const DEBUG_PROFILE_MIN_VERSION = "0.12.3";
|
|
44
|
+
// Renew a cached guardian token that is about to lapse instead of starting
|
|
45
|
+
// a long export on it.
|
|
46
|
+
const TOKEN_EXPIRY_MARGIN_MS = 60 * 1000;
|
|
47
|
+
|
|
48
|
+
function printHelp(): void {
|
|
49
|
+
console.log(`Usage: vellum debug-bundle [name]
|
|
50
|
+
|
|
51
|
+
Send Vellum staff a debug bundle of a self-hosted assistant so they can
|
|
52
|
+
inspect a copy of it. The bundle holds the assistant's workspace, its
|
|
53
|
+
logs, and the gateway's own database. It never holds credentials.
|
|
54
|
+
|
|
55
|
+
Arguments:
|
|
56
|
+
name Assistant ID or unique display name. Defaults to the active
|
|
57
|
+
assistant (see 'vellum use'). Multi-word names need no quotes.
|
|
58
|
+
|
|
59
|
+
Requirements:
|
|
60
|
+
- 'Allow Staff Access' is on for the assistant (Settings > Privacy).
|
|
61
|
+
- You are logged in ('vellum login'), which also registers the
|
|
62
|
+
assistant with the platform.
|
|
63
|
+
- The assistant runs version ${DEBUG_PROFILE_MIN_VERSION} or newer.
|
|
64
|
+
- The assistant is self-hosted. Vellum-hosted assistants need no bundle;
|
|
65
|
+
staff clone them directly.
|
|
66
|
+
|
|
67
|
+
Side effects:
|
|
68
|
+
- Uploads the bundle to Vellum's debug-bundle storage, where it is
|
|
69
|
+
deleted after 7 days. Nothing on this machine changes.
|
|
70
|
+
- Records a 'debug bundle exported' entry in your account's audit history.
|
|
71
|
+
- Waits for the upload to finish (up to 60 minutes for a large workspace).
|
|
72
|
+
|
|
73
|
+
Examples:
|
|
74
|
+
vellum debug-bundle
|
|
75
|
+
vellum debug-bundle my-assistant
|
|
76
|
+
vellum debug-bundle Support Bot
|
|
77
|
+
`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function debugBundle(): Promise<void> {
|
|
81
|
+
const args = process.argv.slice(3);
|
|
82
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
83
|
+
printHelp();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const entry = resolveTargetAssistant(parseAssistantTargetArg(args));
|
|
88
|
+
const reference = formatAssistantReference(entry);
|
|
89
|
+
if (entry.cloud === "vellum") {
|
|
90
|
+
console.error(
|
|
91
|
+
`${reference} is hosted by Vellum, so staff can clone it directly. No bundle is needed.`,
|
|
92
|
+
);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const platformToken = readPlatformToken();
|
|
97
|
+
if (!platformToken) {
|
|
98
|
+
console.error("Not logged in. Run 'vellum login' first.");
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
const platformAssistantId = entry.platformAssistantId?.trim();
|
|
102
|
+
if (!platformAssistantId) {
|
|
103
|
+
console.error(
|
|
104
|
+
`${reference} is not registered with the platform yet. Run 'vellum login' and retry.`,
|
|
105
|
+
);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
const platformUrl = entry.platformBaseUrl?.trim() || getPlatformUrl();
|
|
109
|
+
const organizationId = entry.platformOrganizationId?.trim();
|
|
110
|
+
|
|
111
|
+
// Step 1: a guardian token for the daemon, and a check that the daemon
|
|
112
|
+
// knows the debug profile. This comes before the platform call so a
|
|
113
|
+
// daemon that would export credentials is refused before any URL exists.
|
|
114
|
+
let accessToken = await daemonAccessToken(entry, reference, false);
|
|
115
|
+
const { version } = await localRuntimeIdentity(entry, accessToken);
|
|
116
|
+
const comparison = compareVersions(version, DEBUG_PROFILE_MIN_VERSION);
|
|
117
|
+
if (comparison === null || comparison < 0) {
|
|
118
|
+
console.error(
|
|
119
|
+
`${reference} runs version ${version || "unknown"}, which cannot build a debug bundle. Update it to ${DEBUG_PROFILE_MIN_VERSION} or newer ('vellum upgrade') and retry.`,
|
|
120
|
+
);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Step 2: the platform mints the upload URL, and refuses unless the
|
|
125
|
+
// owner's staff access grant is active.
|
|
126
|
+
const headers = organizationId
|
|
127
|
+
? authHeadersForKnownOrganization(platformToken, organizationId)
|
|
128
|
+
: await authHeaders(platformToken, platformUrl);
|
|
129
|
+
const urlResponse = await loopbackSafeFetch(
|
|
130
|
+
`${platformUrl}/v1/assistants/${encodeURIComponent(platformAssistantId)}/debug-bundle-upload-url/`,
|
|
131
|
+
{ method: "POST", headers },
|
|
132
|
+
);
|
|
133
|
+
if (urlResponse.status === 403) {
|
|
134
|
+
console.error(
|
|
135
|
+
"Staff access is off for this assistant. Turn on 'Allow Staff Access' in Settings > Privacy, then retry.",
|
|
136
|
+
);
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
if (!urlResponse.ok) {
|
|
140
|
+
const detail = await urlResponse.text().catch(() => "");
|
|
141
|
+
console.error(
|
|
142
|
+
`Error: Could not get an upload URL (${urlResponse.status}): ${detail || urlResponse.statusText}`,
|
|
143
|
+
);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
const { url: uploadUrl } = (await urlResponse.json()) as { url: string };
|
|
147
|
+
|
|
148
|
+
// Step 3: the daemon builds and uploads the bundle.
|
|
149
|
+
let jobId: string;
|
|
150
|
+
try {
|
|
151
|
+
({ jobId } = await localRuntimeExportToGcs(entry, accessToken, {
|
|
152
|
+
uploadUrl,
|
|
153
|
+
description: "debug bundle for Vellum staff",
|
|
154
|
+
profile: "debug",
|
|
155
|
+
}));
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (err instanceof MigrationInProgressError) {
|
|
158
|
+
console.error(
|
|
159
|
+
`Error: Another export is already in progress (job ${err.existingJobId}). Wait for it to finish, then retry.`,
|
|
160
|
+
);
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
throw err;
|
|
164
|
+
}
|
|
165
|
+
console.log(`Export started (job ${jobId})...`);
|
|
166
|
+
|
|
167
|
+
// Step 4: wait for the upload so the owner sees a real result. A large
|
|
168
|
+
// export can outlive the guardian token, so a 401 mid-poll re-leases one.
|
|
169
|
+
const terminal = await pollJobUntilDone({
|
|
170
|
+
label: "debug bundle export",
|
|
171
|
+
poll: () => localRuntimePollJobStatus(entry, accessToken, jobId),
|
|
172
|
+
timeoutMs: EXPORT_TIMEOUT_MS,
|
|
173
|
+
refreshOn401: async () => {
|
|
174
|
+
accessToken = await daemonAccessToken(entry, reference, true);
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
if (terminal.status === "failed") {
|
|
178
|
+
console.error(`Error: Export failed: ${terminal.error}`);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
console.log(
|
|
182
|
+
"Debug bundle sent to Vellum. Staff can open it for the next 7 days.",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function daemonAccessToken(
|
|
187
|
+
entry: {
|
|
188
|
+
assistantId: string;
|
|
189
|
+
runtimeUrl: string;
|
|
190
|
+
guardianBootstrapSecret?: string;
|
|
191
|
+
},
|
|
192
|
+
reference: string,
|
|
193
|
+
forceRefresh: boolean,
|
|
194
|
+
): Promise<string> {
|
|
195
|
+
if (!forceRefresh) {
|
|
196
|
+
const cached = loadGuardianToken(entry.assistantId);
|
|
197
|
+
if (
|
|
198
|
+
cached &&
|
|
199
|
+
new Date(cached.accessTokenExpiresAt).getTime() - Date.now() >
|
|
200
|
+
TOKEN_EXPIRY_MARGIN_MS
|
|
201
|
+
) {
|
|
202
|
+
return cached.accessToken;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
return (
|
|
207
|
+
await leaseGuardianToken(
|
|
208
|
+
entry.runtimeUrl,
|
|
209
|
+
entry.assistantId,
|
|
210
|
+
entry.guardianBootstrapSecret,
|
|
211
|
+
)
|
|
212
|
+
).accessToken;
|
|
213
|
+
} catch (err) {
|
|
214
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
215
|
+
if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
|
|
216
|
+
console.error(`Error: Could not connect to ${reference}. Is it running?`);
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
219
|
+
throw err;
|
|
220
|
+
}
|
|
221
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { rollback } from "./commands/rollback";
|
|
|
31
31
|
import { setup } from "./commands/setup";
|
|
32
32
|
import { sleep } from "./commands/sleep";
|
|
33
33
|
import { ssh } from "./commands/ssh";
|
|
34
|
+
import { debugBundle } from "./commands/debug-bundle";
|
|
34
35
|
import { teleport } from "./commands/teleport";
|
|
35
36
|
import { terminal } from "./commands/terminal";
|
|
36
37
|
import { tunnel } from "./commands/tunnel";
|
|
@@ -45,6 +46,7 @@ import { loadGuardianToken } from "./lib/guardian-token";
|
|
|
45
46
|
import { checkHealth } from "./lib/health-check";
|
|
46
47
|
|
|
47
48
|
const commands = {
|
|
49
|
+
"debug-bundle": debugBundle,
|
|
48
50
|
backup,
|
|
49
51
|
clean,
|
|
50
52
|
client,
|
|
@@ -125,6 +127,9 @@ function printHelp(): void {
|
|
|
125
127
|
console.log(" setup Configure API keys interactively");
|
|
126
128
|
console.log(" sleep Stop the assistant process");
|
|
127
129
|
console.log(" ssh SSH into a remote assistant instance");
|
|
130
|
+
console.log(
|
|
131
|
+
" debug-bundle Send Vellum staff a debug bundle of a self-hosted assistant",
|
|
132
|
+
);
|
|
128
133
|
console.log(" teleport Transfer assistant data between environments");
|
|
129
134
|
console.log(" terminal Open a terminal into a managed assistant container");
|
|
130
135
|
console.log(" tunnel Create a tunnel for a locally hosted assistant");
|
|
@@ -116,12 +116,23 @@ async function throwIfInProgress(
|
|
|
116
116
|
export async function localRuntimeExportToGcs(
|
|
117
117
|
entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
|
|
118
118
|
token: string,
|
|
119
|
-
params: {
|
|
119
|
+
params: {
|
|
120
|
+
uploadUrl: string;
|
|
121
|
+
description?: string;
|
|
122
|
+
/**
|
|
123
|
+
* `"debug"` builds a bundle for Vellum staff: no credentials, plus the
|
|
124
|
+
* gateway's database and logs. Omitted means a normal teleport bundle.
|
|
125
|
+
*/
|
|
126
|
+
profile?: "migration" | "debug";
|
|
127
|
+
},
|
|
120
128
|
): Promise<{ jobId: string }> {
|
|
121
129
|
const body: Record<string, unknown> = { upload_url: params.uploadUrl };
|
|
122
130
|
if (params.description !== undefined) {
|
|
123
131
|
body.description = params.description;
|
|
124
132
|
}
|
|
133
|
+
if (params.profile !== undefined) {
|
|
134
|
+
body.profile = params.profile;
|
|
135
|
+
}
|
|
125
136
|
|
|
126
137
|
const response = await loopbackSafeFetch(
|
|
127
138
|
resolveRuntimeMigrationUrl(entry, "export-to-gcs"),
|