warpmetal 0.8.13 → 0.9.0
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/README.md +49 -2
- package/package.json +2 -2
- package/skills/warpmetal/SKILL.md +7 -1
- package/skills/warpmetal/references/cli-reference.md +96 -0
- package/src/account-gateway.js +217 -0
- package/src/account-session.js +209 -0
- package/src/api.js +4 -0
- package/src/cli.js +308 -5
- package/src/customer-auth.js +329 -0
- package/src/models.js +85 -0
- package/src/runtime.js +76 -3
- package/src/state.js +20 -4
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import { CliError } from "./errors.js";
|
|
4
|
+
import { USER_AGENT } from "./version.js";
|
|
5
|
+
|
|
6
|
+
export const CUSTOMER_CLI_CLIENT_ID = "warpmetal-customer-cli";
|
|
7
|
+
export const DEFAULT_IDENTITY_ORIGIN = "https://identity.warpmetal.com";
|
|
8
|
+
export const DEFAULT_ACCOUNT_ORIGIN = "https://warpmetal.com";
|
|
9
|
+
|
|
10
|
+
const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
11
|
+
const SAFE_ERROR = /^[a-z][a-z0-9_]{0,63}$/;
|
|
12
|
+
|
|
13
|
+
export function validateOrigin(value, label) {
|
|
14
|
+
let url;
|
|
15
|
+
try {
|
|
16
|
+
url = new URL(value);
|
|
17
|
+
} catch {
|
|
18
|
+
throw new CliError(`Invalid ${label}: ${value}`, { exitCode: 2 });
|
|
19
|
+
}
|
|
20
|
+
const local = ["localhost", "127.0.0.1", "::1"].includes(url.hostname);
|
|
21
|
+
if (url.protocol !== "https:" && !(local && url.protocol === "http:")) {
|
|
22
|
+
throw new CliError(
|
|
23
|
+
`${label} must use HTTPS (HTTP is allowed for localhost).`,
|
|
24
|
+
{ exitCode: 2 },
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
if (
|
|
28
|
+
url.username ||
|
|
29
|
+
url.password ||
|
|
30
|
+
url.search ||
|
|
31
|
+
url.hash ||
|
|
32
|
+
(url.pathname !== "" && url.pathname !== "/")
|
|
33
|
+
) {
|
|
34
|
+
throw new CliError(
|
|
35
|
+
`${label} must be an origin without credentials, path, query, or fragment data.`,
|
|
36
|
+
{ exitCode: 2 },
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return url.origin;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function responseError(data, status) {
|
|
43
|
+
const candidate =
|
|
44
|
+
typeof data?.error === "string"
|
|
45
|
+
? data.error
|
|
46
|
+
: data?.error?.code || data?.detail?.code;
|
|
47
|
+
const code = SAFE_ERROR.test(candidate || "") ? candidate : "unavailable";
|
|
48
|
+
return new CliError(`Account authorization failed: ${code} (HTTP ${status}).`, {
|
|
49
|
+
exitCode: code === "access_denied" || code === "denied" ? 4 : 3,
|
|
50
|
+
code,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseJson(text) {
|
|
55
|
+
try {
|
|
56
|
+
return text ? JSON.parse(text) : {};
|
|
57
|
+
} catch {
|
|
58
|
+
throw new CliError("The account service returned an invalid response.", {
|
|
59
|
+
exitCode: 3,
|
|
60
|
+
code: "invalid_response",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function combinedSignal(signal, timeoutMs) {
|
|
66
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
67
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function delay(milliseconds, signal) {
|
|
71
|
+
return new Promise((resolveDelay, rejectDelay) => {
|
|
72
|
+
let cancel;
|
|
73
|
+
const timer = setTimeout(() => {
|
|
74
|
+
if (cancel) signal.removeEventListener("abort", cancel);
|
|
75
|
+
resolveDelay();
|
|
76
|
+
}, milliseconds);
|
|
77
|
+
if (!signal) return;
|
|
78
|
+
cancel = () => {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
rejectDelay(
|
|
81
|
+
new CliError("Account authorization was cancelled.", {
|
|
82
|
+
exitCode: 130,
|
|
83
|
+
code: "cancelled",
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
};
|
|
87
|
+
if (signal.aborted) cancel();
|
|
88
|
+
else signal.addEventListener("abort", cancel, { once: true });
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function exactScopes(value) {
|
|
93
|
+
if (typeof value !== "string") return null;
|
|
94
|
+
const scopes = value.split(/\s+/).filter(Boolean);
|
|
95
|
+
if (
|
|
96
|
+
scopes.length === 0 ||
|
|
97
|
+
new Set(scopes).size !== scopes.length ||
|
|
98
|
+
scopes.some((scope) => !["cli:read", "cli:write"].includes(scope))
|
|
99
|
+
) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
return scopes;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function validateToken(data, requestedScopes) {
|
|
106
|
+
const scopes = exactScopes(data?.scope);
|
|
107
|
+
if (
|
|
108
|
+
typeof data?.access_token !== "string" ||
|
|
109
|
+
data.access_token.length === 0 ||
|
|
110
|
+
typeof data?.refresh_token !== "string" ||
|
|
111
|
+
!data.refresh_token.startsWith("wmclr_") ||
|
|
112
|
+
data?.token_type?.toLowerCase() !== "bearer" ||
|
|
113
|
+
!Number.isSafeInteger(data?.expires_in) ||
|
|
114
|
+
data.expires_in <= 0 ||
|
|
115
|
+
!scopes ||
|
|
116
|
+
scopes.some((scope) => !requestedScopes.includes(scope)) ||
|
|
117
|
+
!requestedScopes.every((scope) => scopes.includes(scope))
|
|
118
|
+
) {
|
|
119
|
+
throw new CliError("The account service returned an invalid token response.", {
|
|
120
|
+
exitCode: 3,
|
|
121
|
+
code: "invalid_response",
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
accessToken: data.access_token,
|
|
126
|
+
refreshToken: data.refresh_token,
|
|
127
|
+
accessExpiresAt: new Date(Date.now() + data.expires_in * 1_000).toISOString(),
|
|
128
|
+
scopes,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export class CustomerAuthClient {
|
|
133
|
+
constructor({ identityOrigin, accountOrigin, fetchImpl = globalThis.fetch, timeoutMs = 30_000 } = {}) {
|
|
134
|
+
if (typeof fetchImpl !== "function") {
|
|
135
|
+
throw new CliError("This Node.js runtime does not provide fetch().", {
|
|
136
|
+
exitCode: 2,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
this.identityOrigin = validateOrigin(
|
|
140
|
+
identityOrigin || DEFAULT_IDENTITY_ORIGIN,
|
|
141
|
+
"WarpMetal Identity URL",
|
|
142
|
+
);
|
|
143
|
+
this.accountOrigin = validateOrigin(
|
|
144
|
+
accountOrigin || DEFAULT_ACCOUNT_ORIGIN,
|
|
145
|
+
"WarpMetal account URL",
|
|
146
|
+
);
|
|
147
|
+
this.fetchImpl = fetchImpl;
|
|
148
|
+
this.timeoutMs = timeoutMs;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async #form(path, fields, { signal } = {}) {
|
|
152
|
+
const url = new URL(path, `${this.identityOrigin}/`);
|
|
153
|
+
let response;
|
|
154
|
+
try {
|
|
155
|
+
response = await this.fetchImpl(url, {
|
|
156
|
+
method: "POST",
|
|
157
|
+
redirect: "manual",
|
|
158
|
+
headers: {
|
|
159
|
+
Accept: "application/json",
|
|
160
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
161
|
+
"User-Agent": USER_AGENT,
|
|
162
|
+
},
|
|
163
|
+
body: new URLSearchParams(fields).toString(),
|
|
164
|
+
signal: combinedSignal(signal, this.timeoutMs),
|
|
165
|
+
});
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (signal?.aborted) {
|
|
168
|
+
throw new CliError("Account authorization was cancelled.", {
|
|
169
|
+
exitCode: 130,
|
|
170
|
+
code: "cancelled",
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
throw new CliError(`Could not reach ${url.origin}.`, {
|
|
174
|
+
exitCode: 3,
|
|
175
|
+
code: "unavailable",
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
if (response.status >= 300 && response.status < 400) {
|
|
179
|
+
throw new CliError("Account authorization refused an HTTP redirect.", {
|
|
180
|
+
exitCode: 3,
|
|
181
|
+
code: "redirect_refused",
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const data = parseJson(await response.text());
|
|
185
|
+
return { ok: response.ok, status: response.status, data };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async begin(scopes, { signal } = {}) {
|
|
189
|
+
const result = await this.#form(
|
|
190
|
+
"/oauth/device_authorization",
|
|
191
|
+
{ client_id: CUSTOMER_CLI_CLIENT_ID, scope: scopes.join(" ") },
|
|
192
|
+
{ signal },
|
|
193
|
+
);
|
|
194
|
+
if (!result.ok) throw responseError(result.data, result.status);
|
|
195
|
+
const data = result.data;
|
|
196
|
+
if (
|
|
197
|
+
typeof data?.device_code !== "string" ||
|
|
198
|
+
data.device_code.length === 0 ||
|
|
199
|
+
typeof data?.user_code !== "string" ||
|
|
200
|
+
data.user_code.length === 0 ||
|
|
201
|
+
!Number.isSafeInteger(data?.expires_in) ||
|
|
202
|
+
data.expires_in <= 0 ||
|
|
203
|
+
!Number.isSafeInteger(data?.interval) ||
|
|
204
|
+
data.interval <= 0
|
|
205
|
+
) {
|
|
206
|
+
throw new CliError("The account service returned an invalid device authorization.", {
|
|
207
|
+
exitCode: 3,
|
|
208
|
+
code: "invalid_response",
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
let verification;
|
|
212
|
+
let complete;
|
|
213
|
+
try {
|
|
214
|
+
verification = new URL(data.verification_uri);
|
|
215
|
+
complete = new URL(data.verification_uri_complete);
|
|
216
|
+
} catch {
|
|
217
|
+
throw new CliError("The account service returned an invalid verification URL.", {
|
|
218
|
+
exitCode: 3,
|
|
219
|
+
code: "invalid_response",
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
if (
|
|
223
|
+
verification.origin !== this.accountOrigin ||
|
|
224
|
+
complete.origin !== this.accountOrigin ||
|
|
225
|
+
verification.username ||
|
|
226
|
+
verification.password ||
|
|
227
|
+
complete.username ||
|
|
228
|
+
complete.password ||
|
|
229
|
+
verification.hash ||
|
|
230
|
+
complete.hash ||
|
|
231
|
+
verification.pathname !== "/account/cli" ||
|
|
232
|
+
complete.pathname !== "/account/cli" ||
|
|
233
|
+
verification.search ||
|
|
234
|
+
complete.searchParams.size !== 1 ||
|
|
235
|
+
complete.searchParams.get("user_code") !== data.user_code
|
|
236
|
+
) {
|
|
237
|
+
throw new CliError("The account service returned an untrusted verification URL.", {
|
|
238
|
+
exitCode: 3,
|
|
239
|
+
code: "invalid_response",
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
deviceCode: data.device_code,
|
|
244
|
+
userCode: data.user_code,
|
|
245
|
+
verificationUri: verification.toString(),
|
|
246
|
+
verificationUriComplete: complete.toString(),
|
|
247
|
+
expiresIn: data.expires_in,
|
|
248
|
+
interval: data.interval,
|
|
249
|
+
scopes,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async poll(device, { signal } = {}) {
|
|
254
|
+
const deadline = Date.now() + device.expiresIn * 1_000;
|
|
255
|
+
let interval = device.interval * 1_000;
|
|
256
|
+
while (Date.now() < deadline) {
|
|
257
|
+
await delay(Math.min(interval, Math.max(1, deadline - Date.now())), signal);
|
|
258
|
+
if (Date.now() >= deadline) break;
|
|
259
|
+
const result = await this.#form(
|
|
260
|
+
"/oauth/token",
|
|
261
|
+
{
|
|
262
|
+
grant_type: DEVICE_GRANT,
|
|
263
|
+
device_code: device.deviceCode,
|
|
264
|
+
client_id: CUSTOMER_CLI_CLIENT_ID,
|
|
265
|
+
},
|
|
266
|
+
{ signal },
|
|
267
|
+
);
|
|
268
|
+
if (result.ok) return validateToken(result.data, device.scopes);
|
|
269
|
+
const code =
|
|
270
|
+
typeof result.data?.error === "string"
|
|
271
|
+
? result.data.error
|
|
272
|
+
: result.data?.error?.code || result.data?.detail?.code;
|
|
273
|
+
if (code === "authorization_pending") continue;
|
|
274
|
+
if (code === "slow_down") {
|
|
275
|
+
interval += 5_000;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
throw responseError(result.data, result.status);
|
|
279
|
+
}
|
|
280
|
+
throw new CliError("Account authorization failed: expired_token.", {
|
|
281
|
+
exitCode: 3,
|
|
282
|
+
code: "expired_token",
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async refresh(refreshToken, scopes, { signal } = {}) {
|
|
287
|
+
const result = await this.#form(
|
|
288
|
+
"/oauth/token",
|
|
289
|
+
{
|
|
290
|
+
grant_type: "refresh_token",
|
|
291
|
+
refresh_token: refreshToken,
|
|
292
|
+
client_id: CUSTOMER_CLI_CLIENT_ID,
|
|
293
|
+
},
|
|
294
|
+
{ signal },
|
|
295
|
+
);
|
|
296
|
+
if (!result.ok) throw responseError(result.data, result.status);
|
|
297
|
+
return validateToken(result.data, scopes);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async revoke(refreshToken, { signal } = {}) {
|
|
301
|
+
const result = await this.#form(
|
|
302
|
+
"/oauth/revoke",
|
|
303
|
+
{ token: refreshToken, client_id: CUSTOMER_CLI_CLIENT_ID },
|
|
304
|
+
{ signal },
|
|
305
|
+
);
|
|
306
|
+
if (!result.ok) throw responseError(result.data, result.status);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function openVerificationPage(url, { platform = process.platform, spawnImpl = spawn } = {}) {
|
|
311
|
+
const command =
|
|
312
|
+
platform === "darwin"
|
|
313
|
+
? ["open", [url]]
|
|
314
|
+
: platform === "win32"
|
|
315
|
+
? ["rundll32.exe", ["url.dll,FileProtocolHandler", url]]
|
|
316
|
+
: ["xdg-open", [url]];
|
|
317
|
+
try {
|
|
318
|
+
const child = spawnImpl(command[0], command[1], {
|
|
319
|
+
detached: true,
|
|
320
|
+
stdio: "ignore",
|
|
321
|
+
shell: false,
|
|
322
|
+
});
|
|
323
|
+
child.on?.("error", () => {});
|
|
324
|
+
child.unref?.();
|
|
325
|
+
return true;
|
|
326
|
+
} catch {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
}
|
package/src/models.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { CliError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
export const MODEL_AUTH_MODES = new Set(["api_key", "chatgpt_subscription"]);
|
|
4
|
+
const CATALOG_STATES = new Set(["fresh", "stale", "suppressed"]);
|
|
5
|
+
|
|
6
|
+
function invalidCatalog() {
|
|
7
|
+
throw new CliError("WarpMetal returned an invalid public model catalog.", {
|
|
8
|
+
exitCode: 3,
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function object(value) {
|
|
13
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function entry(value) {
|
|
17
|
+
if (
|
|
18
|
+
!object(value) ||
|
|
19
|
+
typeof value.entryId !== "string" ||
|
|
20
|
+
typeof value.providerId !== "string" ||
|
|
21
|
+
typeof value.modelId !== "string" ||
|
|
22
|
+
!Array.isArray(value.authModes) ||
|
|
23
|
+
value.authModes.some((mode) => !MODEL_AUTH_MODES.has(mode))
|
|
24
|
+
) {
|
|
25
|
+
invalidCatalog();
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Validates the stable public fields needed by the CLI without projecting away
|
|
32
|
+
* snapshot provenance or additive metadata published by the catalog service.
|
|
33
|
+
*/
|
|
34
|
+
export function validateModelCatalog(value) {
|
|
35
|
+
if (
|
|
36
|
+
!object(value) ||
|
|
37
|
+
value.schemaVersion !== 1 ||
|
|
38
|
+
!CATALOG_STATES.has(value.state) ||
|
|
39
|
+
!Array.isArray(value.entries) ||
|
|
40
|
+
!object(value.recommended) ||
|
|
41
|
+
!Array.isArray(value.recommended.managers) ||
|
|
42
|
+
!Array.isArray(value.recommended.workers) ||
|
|
43
|
+
value.recommended.managers.some((id) => typeof id !== "string") ||
|
|
44
|
+
value.recommended.workers.some((id) => typeof id !== "string")
|
|
45
|
+
) {
|
|
46
|
+
invalidCatalog();
|
|
47
|
+
}
|
|
48
|
+
value.entries.forEach(entry);
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function filterModelCatalog(catalog, { provider, authMode } = {}) {
|
|
53
|
+
const published = validateModelCatalog(catalog);
|
|
54
|
+
const entries = published.entries.filter(
|
|
55
|
+
(item) =>
|
|
56
|
+
(provider === undefined || item.providerId === provider) &&
|
|
57
|
+
(authMode === undefined || item.authModes.includes(authMode)),
|
|
58
|
+
);
|
|
59
|
+
const retainedIds = new Set(entries.map((item) => item.entryId));
|
|
60
|
+
return {
|
|
61
|
+
...published,
|
|
62
|
+
entries,
|
|
63
|
+
recommended: {
|
|
64
|
+
...published.recommended,
|
|
65
|
+
managers: published.recommended.managers.filter((id) => retainedIds.has(id)),
|
|
66
|
+
workers: published.recommended.workers.filter((id) => retainedIds.has(id)),
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function modelCatalogHuman(catalog) {
|
|
72
|
+
const header = [
|
|
73
|
+
`Model catalog: ${catalog.state}`,
|
|
74
|
+
`Snapshot: ${catalog.snapshotId ?? "unavailable"}`,
|
|
75
|
+
`Source: ${catalog.sourceAttribution ?? catalog.source?.id ?? "unavailable"}`,
|
|
76
|
+
];
|
|
77
|
+
if (catalog.entries.length === 0) {
|
|
78
|
+
return [...header, "No models match the published catalog and selected filters."].join("\n");
|
|
79
|
+
}
|
|
80
|
+
const entries = catalog.entries.map(
|
|
81
|
+
(item) =>
|
|
82
|
+
`${item.entryId} (${item.authModes.join(", ")})`,
|
|
83
|
+
);
|
|
84
|
+
return [...header, ...entries].join("\n");
|
|
85
|
+
}
|
package/src/runtime.js
CHANGED
|
@@ -15,10 +15,82 @@ const SANDBOX_FIELDS = new Set([
|
|
|
15
15
|
"lifetime",
|
|
16
16
|
"expiresInSeconds",
|
|
17
17
|
]);
|
|
18
|
-
const RUNTIME_FIELDS = new Set(["sandboxes", "setup"]);
|
|
18
|
+
const RUNTIME_FIELDS = new Set(["sandboxes", "setup", "teams"]);
|
|
19
19
|
const SETUP_FIELDS = new Set(["version", "sandboxProfiles"]);
|
|
20
20
|
const SANDBOX_PROFILE_FIELDS = new Set(["sandboxName", "profileId"]);
|
|
21
21
|
|
|
22
|
+
function record(value, allowed, label) {
|
|
23
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
24
|
+
throw new CliError(`${label} must be a JSON object.`, { exitCode: 2 });
|
|
25
|
+
}
|
|
26
|
+
exactFields(value, new Set(allowed), label);
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function invalidTeam(message) {
|
|
31
|
+
throw new CliError(message, { exitCode: 2 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Same structural team-v1 boundary as checkout. Model/provider qualification
|
|
35
|
+
// remains server-owned; parsing an intent never enables runtime execution.
|
|
36
|
+
export function validateTeamIntent(value, sandboxes) {
|
|
37
|
+
record(value, ["version", "teams"], "teams");
|
|
38
|
+
if (value.version !== 1 || !Array.isArray(value.teams) || value.teams.length !== 1) {
|
|
39
|
+
invalidTeam("teams version 1 requires exactly one team.");
|
|
40
|
+
}
|
|
41
|
+
const team = record(value.teams[0], ["name", "members", "links", "startPolicy"], "A team");
|
|
42
|
+
if (typeof team.name !== "string" || !team.name || team.name.length > 64 ||
|
|
43
|
+
team.name.trim() !== team.name || /[\u0000-\u001f\u007f]/.test(team.name)) {
|
|
44
|
+
invalidTeam("Team names must be 1-64 characters without controls or outer whitespace.");
|
|
45
|
+
}
|
|
46
|
+
if (team.startPolicy !== "manual") invalidTeam("Team startPolicy must be manual.");
|
|
47
|
+
if (!Array.isArray(team.members) || team.members.length < 2 || team.members.length > 8) {
|
|
48
|
+
invalidTeam("A team requires 2-8 members.");
|
|
49
|
+
}
|
|
50
|
+
const declared = new Set(sandboxes.map((sandbox) => sandbox.name));
|
|
51
|
+
const names = new Set();
|
|
52
|
+
const members = team.members.map((raw) => {
|
|
53
|
+
const member = record(raw, ["sandboxName", "role", "providerId", "modelId", "authMode"], "A team member");
|
|
54
|
+
if (typeof member.sandboxName !== "string" || !declared.has(member.sandboxName) || names.has(member.sandboxName)) {
|
|
55
|
+
invalidTeam("Each team member must reference a distinct declared sandbox.");
|
|
56
|
+
}
|
|
57
|
+
names.add(member.sandboxName);
|
|
58
|
+
if (!["manager", "worker", "reviewer"].includes(member.role)) invalidTeam("Invalid team member role.");
|
|
59
|
+
if (typeof member.providerId !== "string" || !/^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/.test(member.providerId)) {
|
|
60
|
+
invalidTeam("Team providerId must be an exact lowercase provider identifier.");
|
|
61
|
+
}
|
|
62
|
+
if (member.modelId != null && (typeof member.modelId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(member.modelId))) {
|
|
63
|
+
invalidTeam("Team modelId must be a bare model identifier.");
|
|
64
|
+
}
|
|
65
|
+
if (member.authMode != null && (!["api_key", "chatgpt_subscription"].includes(member.authMode) ||
|
|
66
|
+
(member.authMode === "chatgpt_subscription" && member.providerId !== "openai"))) {
|
|
67
|
+
invalidTeam("Team authMode must be api_key or, for OpenAI, chatgpt_subscription.");
|
|
68
|
+
}
|
|
69
|
+
return { sandboxName: member.sandboxName, role: member.role, providerId: member.providerId,
|
|
70
|
+
...(member.modelId == null ? {} : { modelId: member.modelId }),
|
|
71
|
+
...(member.authMode == null ? {} : { authMode: member.authMode }) };
|
|
72
|
+
});
|
|
73
|
+
const managers = members.filter((member) => member.role === "manager");
|
|
74
|
+
if (managers.length !== 1) invalidTeam("A team requires exactly one manager.");
|
|
75
|
+
const rawLinks = team.links === undefined ? [] : team.links;
|
|
76
|
+
if (!Array.isArray(rawLinks) || rawLinks.length > 56) invalidTeam("Team links must have at most 56 entries.");
|
|
77
|
+
const triples = new Set();
|
|
78
|
+
const links = rawLinks.map((raw) => {
|
|
79
|
+
const link = record(raw, ["from", "to", "capability"], "A team link");
|
|
80
|
+
if (!names.has(link.from) || !names.has(link.to) || link.from === link.to ||
|
|
81
|
+
!["task.delegate", "message.send"].includes(link.capability) ||
|
|
82
|
+
(link.capability === "task.delegate" && link.from !== managers[0].sandboxName)) {
|
|
83
|
+
invalidTeam("Team links require distinct members; only the manager may delegate tasks.");
|
|
84
|
+
}
|
|
85
|
+
const triple = `${link.from}>${link.to}:${link.capability}`;
|
|
86
|
+
if (triples.has(triple)) invalidTeam("Duplicate team links are not allowed.");
|
|
87
|
+
triples.add(triple);
|
|
88
|
+
return { from: link.from, to: link.to, capability: link.capability };
|
|
89
|
+
});
|
|
90
|
+
return { version: 1, teams: [{ name: team.name, members,
|
|
91
|
+
...(links.length ? { links } : {}), startPolicy: "manual" }] };
|
|
92
|
+
}
|
|
93
|
+
|
|
22
94
|
function exactFields(value, allowed, label) {
|
|
23
95
|
const unknown = Object.keys(value).find((field) => !allowed.has(field));
|
|
24
96
|
if (unknown)
|
|
@@ -118,7 +190,8 @@ export async function readSandboxFile(path) {
|
|
|
118
190
|
}
|
|
119
191
|
exactFields(document, RUNTIME_FIELDS, "The sandbox JSON file");
|
|
120
192
|
const sandboxes = validateSandboxBatch(document.sandboxes);
|
|
121
|
-
|
|
193
|
+
const teams = document.teams === undefined ? undefined : validateTeamIntent(document.teams, sandboxes);
|
|
194
|
+
if (document.setup === undefined) return teams ? { sandboxes, teams } : sandboxes;
|
|
122
195
|
const setup = document.setup;
|
|
123
196
|
if (!setup || typeof setup !== "object" || Array.isArray(setup)) {
|
|
124
197
|
throw new CliError("setup must be a JSON object.", { exitCode: 2 });
|
|
@@ -171,7 +244,7 @@ export async function readSandboxFile(path) {
|
|
|
171
244
|
selectedNames.add(input.sandboxName);
|
|
172
245
|
return { sandboxName: input.sandboxName, profileId: input.profileId };
|
|
173
246
|
});
|
|
174
|
-
return { sandboxes, setup: { version: 1, sandboxProfiles } };
|
|
247
|
+
return { sandboxes, setup: { version: 1, sandboxProfiles }, ...(teams ? { teams } : {}) };
|
|
175
248
|
}
|
|
176
249
|
|
|
177
250
|
export function containsTemporary(sandboxes) {
|
package/src/state.js
CHANGED
|
@@ -232,19 +232,35 @@ export class StateStore {
|
|
|
232
232
|
return result;
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
-
async savePreparedOrder(response, checkoutBody, identityId) {
|
|
235
|
+
async savePreparedOrder(response, checkoutBody, identityId, account = null) {
|
|
236
236
|
const { task, ownerToken } = response;
|
|
237
|
-
if (!task?.id || !task?.serverId || !ownerToken) {
|
|
237
|
+
if (!task?.id || !task?.serverId || (account ? ownerToken !== undefined : !ownerToken)) {
|
|
238
238
|
throw new CliError("WarpMetal returned an incomplete prepared order.");
|
|
239
239
|
}
|
|
240
|
+
if (account && (!account.principalId || !account.accountOrigin || !account.identityOrigin)) {
|
|
241
|
+
throw new CliError("The prepared order is missing its account binding.");
|
|
242
|
+
}
|
|
243
|
+
const authority = account ? {
|
|
244
|
+
ownershipMode: "account",
|
|
245
|
+
accountPrincipalId: account.principalId,
|
|
246
|
+
accountOrigin: account.accountOrigin,
|
|
247
|
+
identityOrigin: account.identityOrigin,
|
|
248
|
+
} : { ownerToken };
|
|
240
249
|
await this.update((state) => {
|
|
250
|
+
const previous = state.orders[task.id];
|
|
251
|
+
const previousServer = state.servers[task.serverId];
|
|
252
|
+
if ([previous, previousServer].some((record) => record && (account
|
|
253
|
+
? record.ownerToken || (record.accountPrincipalId && record.accountPrincipalId !== account.principalId)
|
|
254
|
+
: record.ownershipMode === "account"))) {
|
|
255
|
+
throw new CliError("The prepared order conflicts with an existing local ownership binding.");
|
|
256
|
+
}
|
|
241
257
|
state.orders[task.id] = {
|
|
242
258
|
taskId: task.id,
|
|
243
259
|
serverId: task.serverId,
|
|
244
260
|
planId: task.planId,
|
|
245
261
|
checkoutPath: task.checkoutPath,
|
|
246
262
|
checkoutBody,
|
|
247
|
-
|
|
263
|
+
...authority,
|
|
248
264
|
identityId,
|
|
249
265
|
createdAt: new Date().toISOString(),
|
|
250
266
|
};
|
|
@@ -252,7 +268,7 @@ export class StateStore {
|
|
|
252
268
|
...(state.servers[task.serverId] || {}),
|
|
253
269
|
serverId: task.serverId,
|
|
254
270
|
taskId: task.id,
|
|
255
|
-
|
|
271
|
+
...authority,
|
|
256
272
|
identityId,
|
|
257
273
|
};
|
|
258
274
|
if (identityId && state.identities[identityId]) {
|