@extension.dev/mcp 10.10.7 → 10.10.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +119 -0
- package/README.md +4 -2
- package/dist/module.js +1604 -189
- package/dist/src/lib/credentials.d.ts +1 -0
- package/dist/src/lib/device-flow.d.ts +4 -0
- package/dist/src/lib/login-flow.d.ts +7 -0
- package/dist/src/lib/project-batch.d.ts +28 -0
- package/dist/src/lib/project-create-batch.d.ts +42 -0
- package/dist/src/lib/project-create-body.d.ts +14 -0
- package/dist/src/lib/rdp.d.ts +28 -0
- package/dist/src/lib/registry.d.ts +1 -0
- package/dist/src/tools/auth.d.ts +8 -0
- package/dist/src/tools/dom-snapshot.d.ts +1 -0
- package/dist/src/tools/login.d.ts +5 -0
- package/dist/src/tools/project-create.d.ts +10 -2
- package/package.json +2 -2
- package/server.json +2 -2
package/dist/module.js
CHANGED
|
@@ -9,7 +9,7 @@ import node_os from "node:os";
|
|
|
9
9
|
import cross_spawn from "cross-spawn";
|
|
10
10
|
import { PROD_ORIGINS, resolveOrigins } from "@extension.dev/urls/origins";
|
|
11
11
|
import node_crypto, { createHash, randomUUID } from "node:crypto";
|
|
12
|
-
import { consoleProjectPath, wwwNewPath } from "@extension.dev/urls/paths";
|
|
12
|
+
import { ConsoleProjectPage, consoleProjectPath, wwwNewPath } from "@extension.dev/urls/paths";
|
|
13
13
|
import { UserlandProjectPage, userlandUrl } from "@extension.dev/urls/userland";
|
|
14
14
|
import { CLOSE_BAD_HELLO, CLOSE_BAD_INSTANCE, CLOSE_CONTROL_UNAVAILABLE, CLOSE_SLOW_CONSUMER, CONTROL_ENVELOPE_VERSION, CONTROL_WS_PATH, actionsPath, browserArtifactsDir, buildSummaryPath, eventsPath, logsPath, matchesLogQuery, readLogEvents, readReadyContract, readyContractPath as bridge_readyContractPath, sessionArtifactsRootDir, sessionStateDir as bridge_sessionStateDir } from "extension-develop/bridge";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
@@ -92,6 +92,7 @@ var dom_snapshot_namespaceObject = {};
|
|
|
92
92
|
__webpack_require__.r(dom_snapshot_namespaceObject);
|
|
93
93
|
__webpack_require__.d(dom_snapshot_namespaceObject, {
|
|
94
94
|
handler: ()=>dom_snapshot_handler,
|
|
95
|
+
protocolSnapshotExpression: ()=>protocolSnapshotExpression,
|
|
95
96
|
schema: ()=>dom_snapshot_schema
|
|
96
97
|
});
|
|
97
98
|
var eval_namespaceObject = {};
|
|
@@ -242,7 +243,7 @@ __webpack_require__.d(workspace_create_namespaceObject, {
|
|
|
242
243
|
handler: ()=>workspace_create_handler,
|
|
243
244
|
schema: ()=>workspace_create_schema
|
|
244
245
|
});
|
|
245
|
-
var package_namespaceObject = JSON.parse('{"rE":"10.10.
|
|
246
|
+
var package_namespaceObject = JSON.parse('{"rE":"10.10.9","El":{"OP":"4.1.31"}}');
|
|
246
247
|
const ENVELOPE_SCHEMA = 1;
|
|
247
248
|
const collectWarnings = (warnings)=>{
|
|
248
249
|
if (!warnings) return [];
|
|
@@ -578,6 +579,21 @@ function writeCredentials(creds) {
|
|
|
578
579
|
entries
|
|
579
580
|
});
|
|
580
581
|
}
|
|
582
|
+
function writeCredentialBatch(batch) {
|
|
583
|
+
if (0 === batch.length) return null;
|
|
584
|
+
const existing = readCredentialStore();
|
|
585
|
+
const entries = {
|
|
586
|
+
...existing?.entries ?? {}
|
|
587
|
+
};
|
|
588
|
+
for (const creds of batch)entries[credentialKey(creds.workspaceSlug, creds.projectSlug)] = creds;
|
|
589
|
+
const first = batch[0];
|
|
590
|
+
const active = existing?.active && entries[existing.active] ? existing.active : credentialKey(first.workspaceSlug, first.projectSlug);
|
|
591
|
+
return writeStore({
|
|
592
|
+
version: 2,
|
|
593
|
+
active,
|
|
594
|
+
entries
|
|
595
|
+
});
|
|
596
|
+
}
|
|
581
597
|
function clearCredentials(selector) {
|
|
582
598
|
const file = credentialsPath();
|
|
583
599
|
const store = readCredentialStore();
|
|
@@ -659,6 +675,91 @@ const PROJECT_TOKEN_INPUT = {
|
|
|
659
675
|
function noStoredLoginHint(project) {
|
|
660
676
|
return `No stored login for ${project}. extension_auth (action: status) lists the logins on this machine; extension_auth (action: login, project: "${project}") adds one. Logging in to another project no longer replaces this one.`;
|
|
661
677
|
}
|
|
678
|
+
const MAX_BATCH_PROJECTS = 20;
|
|
679
|
+
const MAX_PROJECT_SLUG_LENGTH = 48;
|
|
680
|
+
const MAX_WORKSPACE_SLUG_LENGTH = 64;
|
|
681
|
+
const PLATFORM_CREATES_PER_HOUR = 10;
|
|
682
|
+
const DEFAULT_CREATE_PROJECTS_PER_APPROVAL = 10;
|
|
683
|
+
function capFrom(value, fallback) {
|
|
684
|
+
const count = Number(value);
|
|
685
|
+
if (!Number.isInteger(count) || count < 1) return fallback;
|
|
686
|
+
return Math.min(count, MAX_BATCH_PROJECTS);
|
|
687
|
+
}
|
|
688
|
+
function readBatchCapability(config) {
|
|
689
|
+
const raw = config?.batchOnboarding;
|
|
690
|
+
if (!raw || "object" != typeof raw || Array.isArray(raw)) return null;
|
|
691
|
+
const record = raw;
|
|
692
|
+
return {
|
|
693
|
+
createProjectsPerApproval: capFrom(record.createProjectsPerApproval, DEFAULT_CREATE_PROJECTS_PER_APPROVAL),
|
|
694
|
+
loginProjectsPerApproval: capFrom(record.loginProjectsPerApproval, MAX_BATCH_PROJECTS)
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
const BATCH_UNSUPPORTED_MESSAGE = "This platform does not advertise batch onboarding, so a list of projects was not sent and no device code was spent.";
|
|
698
|
+
const PROJECT_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
699
|
+
function isExactProjectSlug(slug) {
|
|
700
|
+
return slug.length <= MAX_PROJECT_SLUG_LENGTH && PROJECT_SLUG.test(slug);
|
|
701
|
+
}
|
|
702
|
+
function parseProjectBatch(input) {
|
|
703
|
+
if (!Array.isArray(input)) return {
|
|
704
|
+
ok: false,
|
|
705
|
+
message: "projects must be an array of '<workspace>/<project>' names."
|
|
706
|
+
};
|
|
707
|
+
if (input.length < 1 || input.length > MAX_BATCH_PROJECTS) return {
|
|
708
|
+
ok: false,
|
|
709
|
+
message: `projects must name between 1 and ${MAX_BATCH_PROJECTS} projects; got ${input.length}. One approval covers at most ${MAX_BATCH_PROJECTS}, so split a longer list into separate calls.`
|
|
710
|
+
};
|
|
711
|
+
const refs = [];
|
|
712
|
+
const slugs = [];
|
|
713
|
+
let workspace = "";
|
|
714
|
+
for (const entry of input){
|
|
715
|
+
const raw = "string" == typeof entry ? entry.trim() : "";
|
|
716
|
+
if (!/^[^/]+\/[^/]+$/.test(raw)) return {
|
|
717
|
+
ok: false,
|
|
718
|
+
message: `Every entry in projects must be '<workspace>/<project>'; got ${JSON.stringify(entry)}.`
|
|
719
|
+
};
|
|
720
|
+
const slash = raw.indexOf("/");
|
|
721
|
+
const entryWorkspace = raw.slice(0, slash).trim().toLowerCase();
|
|
722
|
+
const slug = raw.slice(slash + 1).trim().toLowerCase();
|
|
723
|
+
if (!entryWorkspace || entryWorkspace.length > MAX_WORKSPACE_SLUG_LENGTH) return {
|
|
724
|
+
ok: false,
|
|
725
|
+
message: `The workspace in '${raw}' must be a slug of at most ${MAX_WORKSPACE_SLUG_LENGTH} characters.`
|
|
726
|
+
};
|
|
727
|
+
if (!isExactProjectSlug(slug)) return {
|
|
728
|
+
ok: false,
|
|
729
|
+
message: `'${raw}' does not name a project by its exact slug. A batch takes each project slug exactly as the platform registers it: lowercase letters and digits joined by single dashes, at most ${MAX_PROJECT_SLUG_LENGTH} characters. The console address bar shows it as console.extension.dev/<workspace>/<project>.`
|
|
730
|
+
};
|
|
731
|
+
if (workspace && entryWorkspace !== workspace) return {
|
|
732
|
+
ok: false,
|
|
733
|
+
message: `One approval covers one workspace: '${raw}' is in '${entryWorkspace}' but the list started in '${workspace}'. Make one call per workspace.`
|
|
734
|
+
};
|
|
735
|
+
workspace = entryWorkspace;
|
|
736
|
+
if (slugs.includes(slug)) return {
|
|
737
|
+
ok: false,
|
|
738
|
+
message: `'${entryWorkspace}/${slug}' is named twice in projects. Name each project once.`
|
|
739
|
+
};
|
|
740
|
+
slugs.push(slug);
|
|
741
|
+
refs.push(`${entryWorkspace}/${slug}`);
|
|
742
|
+
}
|
|
743
|
+
return {
|
|
744
|
+
ok: true,
|
|
745
|
+
batch: {
|
|
746
|
+
workspace,
|
|
747
|
+
slugs,
|
|
748
|
+
refs
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
function sameProjectSet(a, b) {
|
|
753
|
+
if (a.length !== b.length) return false;
|
|
754
|
+
const wanted = new Set(a.map((value)=>value.toLowerCase()));
|
|
755
|
+
return b.every((value)=>wanted.has(value.toLowerCase()));
|
|
756
|
+
}
|
|
757
|
+
function createRateLimitNote() {
|
|
758
|
+
return `The platform creates at most ${PLATFORM_CREATES_PER_HOUR} projects per hour for one approving account, and a provisioning grant lives 15 minutes. Creations the same account already made in the last hour count against it.`;
|
|
759
|
+
}
|
|
760
|
+
function createCapNote(cap) {
|
|
761
|
+
return `One approval creates at most ${cap} projects on this platform. Send the first ${cap}; the next ${cap} can start in a new call, with its own approval, once the hourly creation limit allows.`;
|
|
762
|
+
}
|
|
662
763
|
const DEFAULT_API = PROD_ORIGINS.www;
|
|
663
764
|
function tokenTtlNote(workspaceSlug, projectSlug) {
|
|
664
765
|
const tokensUrl = workspaceSlug && projectSlug ? consoleProjectUrl({
|
|
@@ -710,7 +811,8 @@ async function fetchLoginConfig(apiBase, fetchImpl = fetch) {
|
|
|
710
811
|
return {
|
|
711
812
|
deviceCodeUrl: String(data.deviceCodeUrl || "/api/cli/device/code"),
|
|
712
813
|
deviceTokenUrl: String(data.deviceTokenUrl || "/api/cli/device/token"),
|
|
713
|
-
verificationUri: String(data.verificationUri || `${apiBase.replace(/\/+$/, "")}/device`)
|
|
814
|
+
verificationUri: String(data.verificationUri || `${apiBase.replace(/\/+$/, "")}/device`),
|
|
815
|
+
batch: readBatchCapability(data)
|
|
714
816
|
};
|
|
715
817
|
}
|
|
716
818
|
function persistTokenResponse(args) {
|
|
@@ -734,6 +836,30 @@ function persistTokenResponse(args) {
|
|
|
734
836
|
writeCredentials(creds);
|
|
735
837
|
return creds;
|
|
736
838
|
}
|
|
839
|
+
function persistBatchTokenResponse(args) {
|
|
840
|
+
const raw = Array.isArray(args.data.tokens) ? args.data.tokens : null;
|
|
841
|
+
if (!raw) throw new Error("The platform answered this batch login with a single token and no per-project list, so nothing was stored. It may predate batch login: sign in to each project with its own extension_auth (action: login, project) call.");
|
|
842
|
+
const batch = raw.map((entry)=>{
|
|
843
|
+
const record = entry ?? {};
|
|
844
|
+
const token = String(record.token || "").trim();
|
|
845
|
+
const workspaceSlug = String(record.workspaceSlug || "").trim();
|
|
846
|
+
const projectSlug = String(record.projectSlug || "").trim();
|
|
847
|
+
if (!token || !workspaceSlug || !projectSlug) throw new Error("The batch login returned an entry without a token or a workspace/project scope; nothing was stored. Run extension_auth (action: login) again.");
|
|
848
|
+
return {
|
|
849
|
+
version: 1,
|
|
850
|
+
token,
|
|
851
|
+
workspaceSlug,
|
|
852
|
+
projectSlug,
|
|
853
|
+
expiresAt: Number(record.expiresAt || 0),
|
|
854
|
+
api: args.apiBase,
|
|
855
|
+
provider: "extensiondev"
|
|
856
|
+
};
|
|
857
|
+
});
|
|
858
|
+
const returned = batch.map((creds)=>`${creds.workspaceSlug}/${creds.projectSlug}`);
|
|
859
|
+
if (new Set(returned.map((ref)=>ref.toLowerCase())).size !== returned.length || !sameProjectSet(args.projects, returned)) throw new Error(`The batch login returned tokens scoped to [${returned.join(", ")}], not the requested [${args.projects.join(", ")}]; nothing was stored. Run extension_auth (action: login) again with the intended projects.`);
|
|
860
|
+
writeCredentialBatch(batch);
|
|
861
|
+
return batch;
|
|
862
|
+
}
|
|
737
863
|
const INSTALL_HEADER = "x-extensiondev-install";
|
|
738
864
|
const SESSION_HEADER = "x-extensiondev-session";
|
|
739
865
|
const TOOL_HEADER = "x-extensiondev-tool";
|
|
@@ -884,7 +1010,9 @@ class RegistryAccessTokens {
|
|
|
884
1010
|
return result;
|
|
885
1011
|
}
|
|
886
1012
|
async mint(ref, apiHint) {
|
|
887
|
-
const creds = readValidCredentials(
|
|
1013
|
+
const creds = readValidCredentials(void 0, {
|
|
1014
|
+
project: `${ref.workspace}/${ref.project}`
|
|
1015
|
+
});
|
|
888
1016
|
const token = String(process.env.EXTENSION_DEV_TOKEN || creds?.token || "").trim();
|
|
889
1017
|
if (!token) return {
|
|
890
1018
|
status: "no-credential"
|
|
@@ -1064,14 +1192,33 @@ function consoleBase(apiHint) {
|
|
|
1064
1192
|
function registryBase() {
|
|
1065
1193
|
return mcpOrigins().registry;
|
|
1066
1194
|
}
|
|
1195
|
+
function splitProjectName(name) {
|
|
1196
|
+
const parts = name.split("/");
|
|
1197
|
+
const workspace = String(parts[0] ?? "").trim();
|
|
1198
|
+
const project = String(parts[1] ?? "").trim();
|
|
1199
|
+
if (2 !== parts.length || !workspace || !project) return null;
|
|
1200
|
+
return {
|
|
1201
|
+
workspace,
|
|
1202
|
+
project
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1067
1205
|
function resolveProjectRef(overrides) {
|
|
1068
|
-
|
|
1069
|
-
|
|
1206
|
+
let workspace = String(overrides?.workspace || "").trim();
|
|
1207
|
+
let project = String(overrides?.project || "").trim();
|
|
1208
|
+
if (project.includes("/")) {
|
|
1209
|
+
const named = splitProjectName(project);
|
|
1210
|
+
if (!named) return null;
|
|
1211
|
+
if (workspace && workspace.toLowerCase() !== named.workspace.toLowerCase()) return null;
|
|
1212
|
+
workspace = named.workspace;
|
|
1213
|
+
project = named.project;
|
|
1214
|
+
}
|
|
1070
1215
|
if (workspace && project) return {
|
|
1071
1216
|
workspace,
|
|
1072
1217
|
project
|
|
1073
1218
|
};
|
|
1074
|
-
const creds = readCredentials(
|
|
1219
|
+
const creds = (project ? readCredentials({
|
|
1220
|
+
project
|
|
1221
|
+
}) : null) ?? readCredentials();
|
|
1075
1222
|
const ws = workspace || String(creds?.workspaceSlug || "").trim();
|
|
1076
1223
|
const proj = project || String(creds?.projectSlug || "").trim();
|
|
1077
1224
|
if (!ws || !proj) return null;
|
|
@@ -1080,6 +1227,20 @@ function resolveProjectRef(overrides) {
|
|
|
1080
1227
|
project: proj
|
|
1081
1228
|
};
|
|
1082
1229
|
}
|
|
1230
|
+
function loginProjectRef(selector) {
|
|
1231
|
+
const named = String(selector ?? "").trim();
|
|
1232
|
+
if (!named) return resolveProjectRef();
|
|
1233
|
+
const creds = readCredentials({
|
|
1234
|
+
project: named
|
|
1235
|
+
});
|
|
1236
|
+
const workspace = String(creds?.workspaceSlug || "").trim();
|
|
1237
|
+
const project = String(creds?.projectSlug || "").trim();
|
|
1238
|
+
if (workspace && project) return {
|
|
1239
|
+
workspace,
|
|
1240
|
+
project
|
|
1241
|
+
};
|
|
1242
|
+
return splitProjectName(named);
|
|
1243
|
+
}
|
|
1083
1244
|
function registryFileUrl(ref, file) {
|
|
1084
1245
|
return `${registryBase()}/${encodeURIComponent(ref.workspace)}/${encodeURIComponent(ref.project)}/_extension-dev/${file}`;
|
|
1085
1246
|
}
|
|
@@ -1743,6 +1904,7 @@ const LAUNCH_BROWSER = {
|
|
|
1743
1904
|
};
|
|
1744
1905
|
async function requestDeviceCode(args) {
|
|
1745
1906
|
const doFetch = args.fetchImpl ?? fetch;
|
|
1907
|
+
const batch = Array.isArray(args.projects) && args.projects.length > 0;
|
|
1746
1908
|
const res = await doFetch(`${args.apiBase}${args.path}`, {
|
|
1747
1909
|
method: "POST",
|
|
1748
1910
|
headers: {
|
|
@@ -1750,7 +1912,10 @@ async function requestDeviceCode(args) {
|
|
|
1750
1912
|
accept: "application/json"
|
|
1751
1913
|
},
|
|
1752
1914
|
body: JSON.stringify({
|
|
1753
|
-
...
|
|
1915
|
+
...batch ? {
|
|
1916
|
+
projects: args.projects
|
|
1917
|
+
} : {},
|
|
1918
|
+
...!batch && args.project ? {
|
|
1754
1919
|
project: args.project
|
|
1755
1920
|
} : {},
|
|
1756
1921
|
...args.workspace ? {
|
|
@@ -1774,6 +1939,8 @@ async function requestDeviceCode(args) {
|
|
|
1774
1939
|
if (!res.ok) {
|
|
1775
1940
|
const error = new Error(`Device code request failed (${res.status}): ${data.message || "unknown error"}`);
|
|
1776
1941
|
if ("string" == typeof data.message && data.message.trim()) error.serverMessage = data.message.trim();
|
|
1942
|
+
if ("string" == typeof data.code && data.code.trim()) error.serverCode = data.code.trim();
|
|
1943
|
+
error.httpStatus = res.status;
|
|
1777
1944
|
throw error;
|
|
1778
1945
|
}
|
|
1779
1946
|
const deviceCode = String(data.device_code || "").trim();
|
|
@@ -1792,6 +1959,7 @@ async function pollDeviceGrant(args) {
|
|
|
1792
1959
|
const doFetch = args.fetchImpl ?? fetch;
|
|
1793
1960
|
const deadline = Date.now() + args.budgetMs;
|
|
1794
1961
|
let interval = Math.max(1, args.interval);
|
|
1962
|
+
const batch = Array.isArray(args.projects) && args.projects.length > 0;
|
|
1795
1963
|
for(;;){
|
|
1796
1964
|
const res = await doFetch(`${args.apiBase}${args.path}`, {
|
|
1797
1965
|
method: "POST",
|
|
@@ -1801,7 +1969,10 @@ async function pollDeviceGrant(args) {
|
|
|
1801
1969
|
},
|
|
1802
1970
|
body: JSON.stringify({
|
|
1803
1971
|
device_code: args.deviceCode,
|
|
1804
|
-
...
|
|
1972
|
+
...batch ? {
|
|
1973
|
+
projects: args.projects
|
|
1974
|
+
} : {},
|
|
1975
|
+
...!batch && args.project ? {
|
|
1805
1976
|
project: args.project
|
|
1806
1977
|
} : {},
|
|
1807
1978
|
...args.workspace ? {
|
|
@@ -1823,9 +1994,18 @@ async function pollDeviceGrant(args) {
|
|
|
1823
1994
|
data
|
|
1824
1995
|
};
|
|
1825
1996
|
const error = String(data.error || "");
|
|
1997
|
+
const code = String(data.code || "").trim();
|
|
1998
|
+
const refusal = code ? {
|
|
1999
|
+
code,
|
|
2000
|
+
body: data
|
|
2001
|
+
} : {};
|
|
1826
2002
|
if ("access_denied" === error) return {
|
|
1827
2003
|
ok: false,
|
|
1828
|
-
reason: "denied"
|
|
2004
|
+
reason: "denied",
|
|
2005
|
+
...data.message ? {
|
|
2006
|
+
message: String(data.message)
|
|
2007
|
+
} : {},
|
|
2008
|
+
...refusal
|
|
1829
2009
|
};
|
|
1830
2010
|
if ("expired_token" === error) return {
|
|
1831
2011
|
ok: false,
|
|
@@ -1835,12 +2015,14 @@ async function pollDeviceGrant(args) {
|
|
|
1835
2015
|
else if (error && "authorization_pending" !== error) return {
|
|
1836
2016
|
ok: false,
|
|
1837
2017
|
reason: "error",
|
|
1838
|
-
message: String(data.message || error)
|
|
2018
|
+
message: String(data.message || error),
|
|
2019
|
+
...refusal
|
|
1839
2020
|
};
|
|
1840
2021
|
else if (!error && !res.ok) return {
|
|
1841
2022
|
ok: false,
|
|
1842
2023
|
reason: "error",
|
|
1843
|
-
message: `Device token poll failed (${res.status}): ${String(data.message || text || "no response body").slice(0, 200)}
|
|
2024
|
+
message: `Device token poll failed (${res.status}): ${String(data.message || text || "no response body").slice(0, 200)}`,
|
|
2025
|
+
...refusal
|
|
1844
2026
|
};
|
|
1845
2027
|
if (Date.now() + 1000 * interval >= deadline) return {
|
|
1846
2028
|
ok: false,
|
|
@@ -1863,55 +2045,716 @@ async function pollDeviceToken(args) {
|
|
|
1863
2045
|
creds
|
|
1864
2046
|
};
|
|
1865
2047
|
} catch (err) {
|
|
1866
|
-
return {
|
|
1867
|
-
ok: false,
|
|
1868
|
-
reason: "error",
|
|
1869
|
-
message: err?.message ? String(err.message) : String(err)
|
|
1870
|
-
};
|
|
2048
|
+
return {
|
|
2049
|
+
ok: false,
|
|
2050
|
+
reason: "error",
|
|
2051
|
+
message: err?.message ? String(err.message) : String(err)
|
|
2052
|
+
};
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
const FREE_ALLOWANCE_PHRASE = "counts against your free allowance";
|
|
2056
|
+
function allowanceWallUrl(apiHint) {
|
|
2057
|
+
return `${mcpOrigins(apiHint).www.replace(/\/+$/, "")}/pricing`;
|
|
2058
|
+
}
|
|
2059
|
+
function allowance_asRecord(value) {
|
|
2060
|
+
return value && "object" == typeof value && !Array.isArray(value) ? value : null;
|
|
2061
|
+
}
|
|
2062
|
+
function asCount(value) {
|
|
2063
|
+
return "number" == typeof value && Number.isFinite(value) && value >= 0 ? value : null;
|
|
2064
|
+
}
|
|
2065
|
+
function readPlatformAllowance(body) {
|
|
2066
|
+
const record = allowance_asRecord(body);
|
|
2067
|
+
if (!record) return null;
|
|
2068
|
+
for (const key of [
|
|
2069
|
+
"allowance",
|
|
2070
|
+
"quota"
|
|
2071
|
+
]){
|
|
2072
|
+
const nested = allowance_asRecord(record[key]);
|
|
2073
|
+
if (!nested) continue;
|
|
2074
|
+
const used = asCount(nested.used);
|
|
2075
|
+
const limit = asCount(nested.limit);
|
|
2076
|
+
if (null !== used && null !== limit) return {
|
|
2077
|
+
used,
|
|
2078
|
+
limit
|
|
2079
|
+
};
|
|
2080
|
+
}
|
|
2081
|
+
return null;
|
|
2082
|
+
}
|
|
2083
|
+
function spendNarration(options) {
|
|
2084
|
+
const counted = readPlatformAllowance(options.body);
|
|
2085
|
+
return {
|
|
2086
|
+
spent: `${options.what} ran on extension.dev's machines and ${FREE_ALLOWANCE_PHRASE}.`,
|
|
2087
|
+
remains: counted ? `The platform reports ${counted.used} of ${counted.limit} used.` : "The platform sent no remaining count on this call, and this client never invents one. When the allowance runs out, the platform refuses with its own numbers.",
|
|
2088
|
+
wall: `What the free allowance covers and when the paid plan starts are published at ${allowanceWallUrl(options.api)}.`
|
|
2089
|
+
};
|
|
2090
|
+
}
|
|
2091
|
+
function buildCreateBody(args) {
|
|
2092
|
+
const installCommand = String(args.installCommand || "npm install").trim();
|
|
2093
|
+
const buildCommand = String(args.buildCommand || "npm run build").trim();
|
|
2094
|
+
const wanted = new Set((Array.isArray(args.browsers) && args.browsers.length ? args.browsers : [
|
|
2095
|
+
"chrome"
|
|
2096
|
+
]).map((name)=>String(name).trim().toLowerCase()));
|
|
2097
|
+
const several = wanted.size > 1;
|
|
2098
|
+
const outputFor = (name)=>{
|
|
2099
|
+
const override = args.outputDirectories?.[name];
|
|
2100
|
+
if ("string" == typeof override && override.trim()) return override.trim();
|
|
2101
|
+
const pattern = String(args.outputDirectory || "").trim();
|
|
2102
|
+
if (pattern.includes("<browser>")) return pattern.replaceAll("<browser>", name);
|
|
2103
|
+
if (pattern && !several) return pattern;
|
|
2104
|
+
return `dist/${name}`;
|
|
2105
|
+
};
|
|
2106
|
+
const browser = (name)=>({
|
|
2107
|
+
enabled: wanted.has(name),
|
|
2108
|
+
installCommand,
|
|
2109
|
+
buildCommand,
|
|
2110
|
+
outputDirectory: outputFor(name)
|
|
2111
|
+
});
|
|
2112
|
+
return {
|
|
2113
|
+
info: {
|
|
2114
|
+
id: "",
|
|
2115
|
+
name: args.projectSlug,
|
|
2116
|
+
displayName: String(args.displayName || args.projectSlug).trim(),
|
|
2117
|
+
description: String(args.description || `Browser extension project for ${args.owner}/${args.repoName}.`).trim()
|
|
2118
|
+
},
|
|
2119
|
+
build: {
|
|
2120
|
+
chrome: browser("chrome"),
|
|
2121
|
+
edge: browser("edge"),
|
|
2122
|
+
firefox: browser("firefox")
|
|
2123
|
+
},
|
|
2124
|
+
deployment: {
|
|
2125
|
+
branch: "",
|
|
2126
|
+
nodeVersion: "",
|
|
2127
|
+
runWhatsNew: false,
|
|
2128
|
+
runExtensionExecutables: false
|
|
2129
|
+
},
|
|
2130
|
+
github: {
|
|
2131
|
+
owner: args.owner,
|
|
2132
|
+
repo: args.repoName,
|
|
2133
|
+
installationId: args.installationId,
|
|
2134
|
+
createdAt: new Date().toISOString(),
|
|
2135
|
+
pullRequestComments: true,
|
|
2136
|
+
commitComments: false
|
|
2137
|
+
},
|
|
2138
|
+
workspaceSlug: args.workspace,
|
|
2139
|
+
createdFrom: {
|
|
2140
|
+
kind: "repository",
|
|
2141
|
+
ref: `${args.owner}/${args.repoName}`
|
|
2142
|
+
}
|
|
2143
|
+
};
|
|
2144
|
+
}
|
|
2145
|
+
const project_create_batch_COMMAND = "extension_project_create";
|
|
2146
|
+
const FIRST_CALL_BUDGET_MS = 8000;
|
|
2147
|
+
const RESUME_BUDGET_MS = 22000;
|
|
2148
|
+
const SLICE_BUDGET_MS = 20000;
|
|
2149
|
+
const GRANT_EXPIRY_MARGIN_SECONDS = 5;
|
|
2150
|
+
const ENTRY_KEYS = [
|
|
2151
|
+
"project",
|
|
2152
|
+
"repo",
|
|
2153
|
+
"displayName",
|
|
2154
|
+
"description",
|
|
2155
|
+
"installCommand",
|
|
2156
|
+
"buildCommand",
|
|
2157
|
+
"outputDirectory",
|
|
2158
|
+
"browsers",
|
|
2159
|
+
"outputDirectories"
|
|
2160
|
+
];
|
|
2161
|
+
const ENTRY_STRING_KEYS = [
|
|
2162
|
+
"displayName",
|
|
2163
|
+
"description",
|
|
2164
|
+
"installCommand",
|
|
2165
|
+
"buildCommand",
|
|
2166
|
+
"outputDirectory"
|
|
2167
|
+
];
|
|
2168
|
+
const BROWSERS = [
|
|
2169
|
+
"chrome",
|
|
2170
|
+
"edge",
|
|
2171
|
+
"firefox"
|
|
2172
|
+
];
|
|
2173
|
+
const SINGLE_ONLY_KEYS = [
|
|
2174
|
+
"project",
|
|
2175
|
+
"repo",
|
|
2176
|
+
"displayName",
|
|
2177
|
+
"description"
|
|
2178
|
+
];
|
|
2179
|
+
const sessions = new Map();
|
|
2180
|
+
const SESSION_KEEP_SECONDS = 3600;
|
|
2181
|
+
function sweepSessions(nowSeconds) {
|
|
2182
|
+
for (const [key, session] of sessions)if (!session.busy) {
|
|
2183
|
+
if (session.grantExpiresAt <= nowSeconds) session.grant = "";
|
|
2184
|
+
if (session.grantExpiresAt + SESSION_KEEP_SECONDS <= nowSeconds) sessions.delete(key);
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
function fail(name, message, status, code, extra) {
|
|
2188
|
+
return envelope_envelope({
|
|
2189
|
+
ok: false,
|
|
2190
|
+
command: project_create_batch_COMMAND,
|
|
2191
|
+
status,
|
|
2192
|
+
error: {
|
|
2193
|
+
code,
|
|
2194
|
+
name,
|
|
2195
|
+
message
|
|
2196
|
+
},
|
|
2197
|
+
...extra?.value ? {
|
|
2198
|
+
value: extra.value
|
|
2199
|
+
} : {},
|
|
2200
|
+
...extra?.hint ? {
|
|
2201
|
+
hint: extra.hint
|
|
2202
|
+
} : {}
|
|
2203
|
+
});
|
|
2204
|
+
}
|
|
2205
|
+
function laneClosedHint() {
|
|
2206
|
+
return `Create the projects in the console at ${consoleBase()} (workspace page, New project), then run extension_auth (action: login) against them. While the public hold is on, headless creation is open only to workspaces on the platform's allowlist (WWW_MCP_ACTION_ALLOWED_WORKSPACES), the same list that already lets publish, submit and promote run headlessly; this workspace is not on it, so the console is the route.`;
|
|
2207
|
+
}
|
|
2208
|
+
function isRecord(value) {
|
|
2209
|
+
return Boolean(value) && "object" == typeof value && !Array.isArray(value);
|
|
2210
|
+
}
|
|
2211
|
+
function parseBatchCreateArgs(args) {
|
|
2212
|
+
for (const key of SINGLE_ONLY_KEYS){
|
|
2213
|
+
const value = args[key];
|
|
2214
|
+
if (null != value && "" !== String(value).trim()) return {
|
|
2215
|
+
ok: false,
|
|
2216
|
+
message: `With projects, ${key} belongs inside each entry, not beside the list. Pass either the single form (project, repo) or projects, not both.`
|
|
2217
|
+
};
|
|
2218
|
+
}
|
|
2219
|
+
if (!Array.isArray(args.projects)) return {
|
|
2220
|
+
ok: false,
|
|
2221
|
+
message: "projects must be an array of entries shaped { project: '<workspace>/<project>', repo: '<owner>/<repo>' }."
|
|
2222
|
+
};
|
|
2223
|
+
const refs = [];
|
|
2224
|
+
for (const entry of args.projects){
|
|
2225
|
+
if (!isRecord(entry)) return {
|
|
2226
|
+
ok: false,
|
|
2227
|
+
message: `Every entry in projects must be an object shaped { project, repo }; got ${JSON.stringify(entry)}. Each new project needs its own source repository, so a bare name is not enough.`
|
|
2228
|
+
};
|
|
2229
|
+
const unknown = Object.keys(entry).filter((key)=>!ENTRY_KEYS.includes(key));
|
|
2230
|
+
if (unknown.length) return {
|
|
2231
|
+
ok: false,
|
|
2232
|
+
message: `Unknown key ${unknown.map((key)=>`'${key}'`).join(", ")} in a projects entry. An entry takes: ${ENTRY_KEYS.join(", ")}.`
|
|
2233
|
+
};
|
|
2234
|
+
refs.push(entry.project);
|
|
2235
|
+
}
|
|
2236
|
+
const parsed = parseProjectBatch(refs);
|
|
2237
|
+
if (!parsed.ok) return parsed;
|
|
2238
|
+
const entries = [];
|
|
2239
|
+
for (const [index, raw] of args.projects.entries()){
|
|
2240
|
+
const ref = parsed.batch.refs[index];
|
|
2241
|
+
const slug = parsed.batch.slugs[index];
|
|
2242
|
+
const repo = "string" == typeof raw.repo ? raw.repo.trim() : "";
|
|
2243
|
+
if (!/^[^/]+\/[^/]+$/.test(repo)) return {
|
|
2244
|
+
ok: false,
|
|
2245
|
+
message: `The entry for '${ref}' needs repo as '<owner>/<repo>', the GitHub repository that project's source is pushed to.`
|
|
2246
|
+
};
|
|
2247
|
+
for (const key of ENTRY_STRING_KEYS)if (void 0 !== raw[key] && "string" != typeof raw[key]) return {
|
|
2248
|
+
ok: false,
|
|
2249
|
+
message: `In the entry for '${ref}', ${key} must be a string.`
|
|
2250
|
+
};
|
|
2251
|
+
if (void 0 !== raw.browsers) {
|
|
2252
|
+
const list = raw.browsers;
|
|
2253
|
+
if (!Array.isArray(list) || 0 === list.length || list.some((name)=>!BROWSERS.includes(String(name)))) return {
|
|
2254
|
+
ok: false,
|
|
2255
|
+
message: `In the entry for '${ref}', browsers must be a non-empty array of: ${BROWSERS.join(", ")}.`
|
|
2256
|
+
};
|
|
2257
|
+
}
|
|
2258
|
+
if (void 0 !== raw.outputDirectories && !isRecord(raw.outputDirectories)) return {
|
|
2259
|
+
ok: false,
|
|
2260
|
+
message: `In the entry for '${ref}', outputDirectories must be an object such as {"edge": "build/edge"}.`
|
|
2261
|
+
};
|
|
2262
|
+
const [owner = "", repoName = ""] = repo.split("/");
|
|
2263
|
+
entries.push({
|
|
2264
|
+
ref,
|
|
2265
|
+
workspace: parsed.batch.workspace,
|
|
2266
|
+
slug,
|
|
2267
|
+
repo,
|
|
2268
|
+
owner,
|
|
2269
|
+
repoName,
|
|
2270
|
+
displayName: raw.displayName,
|
|
2271
|
+
description: raw.description,
|
|
2272
|
+
installCommand: raw.installCommand ?? args.installCommand,
|
|
2273
|
+
buildCommand: raw.buildCommand ?? args.buildCommand,
|
|
2274
|
+
outputDirectory: raw.outputDirectory ?? args.outputDirectory,
|
|
2275
|
+
browsers: raw.browsers ?? args.browsers,
|
|
2276
|
+
outputDirectories: raw.outputDirectories ?? args.outputDirectories
|
|
2277
|
+
});
|
|
2278
|
+
}
|
|
2279
|
+
return {
|
|
2280
|
+
ok: true,
|
|
2281
|
+
workspace: parsed.batch.workspace,
|
|
2282
|
+
entries
|
|
2283
|
+
};
|
|
2284
|
+
}
|
|
2285
|
+
function pendingEnvelope(args) {
|
|
2286
|
+
const complete = String(args.verificationUriComplete || "").trim();
|
|
2287
|
+
const hasCompleteLink = complete.length > 0 && complete !== args.verificationUri;
|
|
2288
|
+
const again = "then call extension_project_create again with this deviceCode and the same arguments";
|
|
2289
|
+
const page = `The page lists all ${args.refs.length} projects this one approval creates and says this device is also signed in to each for 7 days`;
|
|
2290
|
+
const hint = args.userCode ? hasCompleteLink ? `Open ${complete} and approve creating the projects (code ${args.userCode} is pre-filled). ${page}; ${again}. If the page asks for a code, enter ${args.userCode} at ${args.verificationUri}.` : `Open ${args.verificationUri}, enter code ${args.userCode} and approve creating the projects. ${page}; ${again}.` : `Still waiting for approval at ${args.verificationUri}. Approve there, ${again}.`;
|
|
2291
|
+
return envelope_envelope({
|
|
2292
|
+
ok: true,
|
|
2293
|
+
command: project_create_batch_COMMAND,
|
|
2294
|
+
status: "authorization-pending",
|
|
2295
|
+
value: {
|
|
2296
|
+
...args.userCode ? {
|
|
2297
|
+
userCode: args.userCode
|
|
2298
|
+
} : {},
|
|
2299
|
+
verificationUri: args.verificationUri,
|
|
2300
|
+
...args.userCode && hasCompleteLink ? {
|
|
2301
|
+
verificationUriComplete: complete
|
|
2302
|
+
} : {},
|
|
2303
|
+
deviceCode: args.deviceCode,
|
|
2304
|
+
projects: args.refs
|
|
2305
|
+
},
|
|
2306
|
+
hint
|
|
2307
|
+
});
|
|
2308
|
+
}
|
|
2309
|
+
const STOP_CODES = new Set([
|
|
2310
|
+
"TOKEN_EXPIRED",
|
|
2311
|
+
"BAD_TOKEN",
|
|
2312
|
+
"AUTH_REQUIRED",
|
|
2313
|
+
"NOT_A_PROVISIONING_GRANT",
|
|
2314
|
+
"MEMBERSHIP_REVOKED",
|
|
2315
|
+
"WORKSPACE_NOT_FOUND",
|
|
2316
|
+
"CLI_PROJECT_CREATE_DISABLED",
|
|
2317
|
+
"PLATFORM_NOT_OPEN",
|
|
2318
|
+
"RATE_LIMITED",
|
|
2319
|
+
"PROJECT_LIMIT_EXCEEDED",
|
|
2320
|
+
"INSTALLATION_ABSENT",
|
|
2321
|
+
"INSTALLATION_ORG_UNSUPPORTED",
|
|
2322
|
+
"INSTALLATION_AMBIGUOUS"
|
|
2323
|
+
]);
|
|
2324
|
+
function refusalHint(code, ref, retryAfterSeconds) {
|
|
2325
|
+
if ("PROJECT_EXISTS" === code) return `${ref} already exists, so there was nothing to create and no token came back. Sign in to it with extension_auth (action: login).`;
|
|
2326
|
+
if ("RATE_LIMITED" === code) return `${createRateLimitNote()}${retryAfterSeconds ? ` The platform says to wait ${retryAfterSeconds} seconds.` : ""} The grant will have expired by then, so the projects not created need a new extension_project_create call, and its approval, after the wait.`;
|
|
2327
|
+
if ("TOKEN_EXPIRED" === code) return "The provisioning grant lives 15 minutes and ran out. Start a new extension_project_create call for the projects not created.";
|
|
2328
|
+
if ("PROJECT_LIMIT_EXCEEDED" === code) return "The workspace is at its plan's project limit. Nothing more can be created in it until a project is removed or the plan changes.";
|
|
2329
|
+
if ("RESERVED_PROJECT_SLUG" === code) return "That slug is reserved on extension.dev. Pick another name for this project.";
|
|
2330
|
+
}
|
|
2331
|
+
async function createOne(session, entry, installationId) {
|
|
2332
|
+
const url = `${session.apiBase}/api/cli/projects/create`;
|
|
2333
|
+
const body = {
|
|
2334
|
+
...buildCreateBody({
|
|
2335
|
+
workspace: entry.workspace,
|
|
2336
|
+
projectSlug: entry.slug,
|
|
2337
|
+
owner: entry.owner,
|
|
2338
|
+
repoName: entry.repoName,
|
|
2339
|
+
installationId,
|
|
2340
|
+
displayName: entry.displayName,
|
|
2341
|
+
description: entry.description,
|
|
2342
|
+
installCommand: entry.installCommand,
|
|
2343
|
+
buildCommand: entry.buildCommand,
|
|
2344
|
+
outputDirectory: entry.outputDirectory,
|
|
2345
|
+
browsers: entry.browsers,
|
|
2346
|
+
outputDirectories: entry.outputDirectories
|
|
2347
|
+
}),
|
|
2348
|
+
project: entry.ref
|
|
2349
|
+
};
|
|
2350
|
+
let res;
|
|
2351
|
+
try {
|
|
2352
|
+
res = await fetch(url, {
|
|
2353
|
+
method: "POST",
|
|
2354
|
+
headers: {
|
|
2355
|
+
authorization: `Bearer ${session.grant}`,
|
|
2356
|
+
"content-type": "application/json",
|
|
2357
|
+
...identityHeaders(project_create_batch_COMMAND)
|
|
2358
|
+
},
|
|
2359
|
+
body: JSON.stringify(body)
|
|
2360
|
+
});
|
|
2361
|
+
} catch (err) {
|
|
2362
|
+
session.unanswered += 1;
|
|
2363
|
+
const message = `Could not reach ${url}: ${err?.message || err}`;
|
|
2364
|
+
return {
|
|
2365
|
+
row: {
|
|
2366
|
+
project: entry.ref,
|
|
2367
|
+
status: "unconfirmed",
|
|
2368
|
+
message,
|
|
2369
|
+
hint: `The request for ${entry.ref} left this machine and no answer came back, so the project may or may not exist. It is not retried, because a second create racing the first would be two builds claiming one name. Check the console at ${consoleBase()}; if it exists, sign in with extension_auth (action: login).`
|
|
2370
|
+
},
|
|
2371
|
+
halt: true,
|
|
2372
|
+
...session.unanswered >= 2 ? {
|
|
2373
|
+
stop: {
|
|
2374
|
+
code: "PLATFORM_UNREACHABLE",
|
|
2375
|
+
message
|
|
2376
|
+
}
|
|
2377
|
+
} : {}
|
|
2378
|
+
};
|
|
2379
|
+
}
|
|
2380
|
+
session.unanswered = 0;
|
|
2381
|
+
const text = await res.text();
|
|
2382
|
+
let data;
|
|
2383
|
+
try {
|
|
2384
|
+
data = JSON.parse(text);
|
|
2385
|
+
} catch {
|
|
2386
|
+
data = {
|
|
2387
|
+
message: text
|
|
2388
|
+
};
|
|
2389
|
+
}
|
|
2390
|
+
if (!res.ok) {
|
|
2391
|
+
const held = sawPlatformHold(res, data);
|
|
2392
|
+
const code = held ? "PLATFORM_NOT_OPEN" : String(data.code || "").trim() || `HTTP_${res.status}`;
|
|
2393
|
+
const message = String(data.message || data.error || text || "unknown error").slice(0, 500);
|
|
2394
|
+
const connectUrl = String(data.connectUrl || "").trim();
|
|
2395
|
+
const retryAfterSeconds = Number(data.retryAfterSeconds || res.headers.get("retry-after") || 0) || void 0;
|
|
2396
|
+
const hint = refusalHint(code, entry.ref, retryAfterSeconds);
|
|
2397
|
+
const row = {
|
|
2398
|
+
project: entry.ref,
|
|
2399
|
+
status: "refused",
|
|
2400
|
+
httpStatus: res.status,
|
|
2401
|
+
code,
|
|
2402
|
+
message,
|
|
2403
|
+
...connectUrl ? {
|
|
2404
|
+
connectUrl
|
|
2405
|
+
} : {},
|
|
2406
|
+
...retryAfterSeconds ? {
|
|
2407
|
+
retryAfterSeconds
|
|
2408
|
+
} : {},
|
|
2409
|
+
...hint ? {
|
|
2410
|
+
hint
|
|
2411
|
+
} : {}
|
|
2412
|
+
};
|
|
2413
|
+
return STOP_CODES.has(code) ? {
|
|
2414
|
+
row,
|
|
2415
|
+
stop: {
|
|
2416
|
+
code,
|
|
2417
|
+
message,
|
|
2418
|
+
body: data,
|
|
2419
|
+
held
|
|
2420
|
+
}
|
|
2421
|
+
} : {
|
|
2422
|
+
row
|
|
2423
|
+
};
|
|
2424
|
+
}
|
|
2425
|
+
session.lastBody = data;
|
|
2426
|
+
const finalWorkspace = String(data.workspaceSlug || entry.workspace).trim();
|
|
2427
|
+
const finalProject = String(data.projectSlug || entry.slug).trim();
|
|
2428
|
+
const consoleUrl = consoleProjectUrl({
|
|
2429
|
+
workspace: finalWorkspace,
|
|
2430
|
+
project: finalProject
|
|
2431
|
+
}, "");
|
|
2432
|
+
const token = String(data.token || "").trim();
|
|
2433
|
+
const scoped = finalWorkspace.toLowerCase() === entry.workspace && finalProject.toLowerCase() === entry.slug;
|
|
2434
|
+
if (true === data.tokenIssued && token && scoped) {
|
|
2435
|
+
const expiresAt = Number(data.expiresAt || 0);
|
|
2436
|
+
writeCredentialBatch([
|
|
2437
|
+
{
|
|
2438
|
+
version: 1,
|
|
2439
|
+
token,
|
|
2440
|
+
workspaceSlug: finalWorkspace,
|
|
2441
|
+
projectSlug: finalProject,
|
|
2442
|
+
expiresAt,
|
|
2443
|
+
api: session.apiBase,
|
|
2444
|
+
provider: "extensiondev"
|
|
2445
|
+
}
|
|
2446
|
+
]);
|
|
2447
|
+
return {
|
|
2448
|
+
row: {
|
|
2449
|
+
project: entry.ref,
|
|
2450
|
+
status: "created",
|
|
2451
|
+
loggedIn: true,
|
|
2452
|
+
projectId: data.projectId ?? null,
|
|
2453
|
+
consoleUrl,
|
|
2454
|
+
expiresAt: expiresAt ? new Date(1000 * expiresAt).toISOString() : null
|
|
2455
|
+
}
|
|
2456
|
+
};
|
|
2457
|
+
}
|
|
2458
|
+
const tokenCode = scoped ? String(data.tokenCode || "").trim() || "NO_TOKEN_IN_RESPONSE" : "PROJECT_NAME_MISMATCH";
|
|
2459
|
+
return {
|
|
2460
|
+
row: {
|
|
2461
|
+
project: entry.ref,
|
|
2462
|
+
status: "created",
|
|
2463
|
+
loggedIn: false,
|
|
2464
|
+
projectId: data.projectId ?? null,
|
|
2465
|
+
consoleUrl,
|
|
2466
|
+
tokenCode,
|
|
2467
|
+
hint: scoped ? `${entry.ref} was created but the platform issued no token for it. Sign in with extension_auth (action: login).` : `The platform registered this project as ${finalWorkspace}/${finalProject}, not the ${entry.ref} that was asked for, so no login was stored. Sign in to the name it has with extension_auth (action: login).`
|
|
2468
|
+
}
|
|
2469
|
+
};
|
|
2470
|
+
}
|
|
2471
|
+
function rowsInOrder(session) {
|
|
2472
|
+
return session.entries.map((entry)=>session.rows.get(entry.ref) ?? {
|
|
2473
|
+
project: entry.ref,
|
|
2474
|
+
status: "pending"
|
|
2475
|
+
});
|
|
2476
|
+
}
|
|
2477
|
+
function quoteList(refs) {
|
|
2478
|
+
return `[${refs.map((ref)=>`'${ref}'`).join(", ")}]`;
|
|
2479
|
+
}
|
|
2480
|
+
function finalEnvelope(session) {
|
|
2481
|
+
const stop = session.stop;
|
|
2482
|
+
for (const entry of session.entries)if (!session.rows.has(entry.ref)) session.rows.set(entry.ref, {
|
|
2483
|
+
project: entry.ref,
|
|
2484
|
+
status: "not-attempted",
|
|
2485
|
+
code: stop?.code ?? "NOT_ATTEMPTED",
|
|
2486
|
+
message: stop ? `Not attempted: an earlier project in this list was refused with ${stop.code}, which applies to the whole approval.` : "Not attempted."
|
|
2487
|
+
});
|
|
2488
|
+
const rows = rowsInOrder(session);
|
|
2489
|
+
const created = rows.filter((row)=>"created" === row.status);
|
|
2490
|
+
const loggedIn = created.filter((row)=>"created" === row.status && row.loggedIn);
|
|
2491
|
+
const needLogin = [
|
|
2492
|
+
...created.filter((row)=>"created" === row.status && !row.loggedIn),
|
|
2493
|
+
...rows.filter((row)=>"refused" === row.status && "PROJECT_EXISTS" === row.code)
|
|
2494
|
+
].map((row)=>row.project);
|
|
2495
|
+
const retry = rows.filter((row)=>"not-attempted" === row.status || "refused" === row.status && "PROJECT_EXISTS" !== row.code).map((row)=>row.project);
|
|
2496
|
+
const unconfirmed = rows.filter((row)=>"unconfirmed" === row.status).map((row)=>row.project);
|
|
2497
|
+
if (0 === created.length && stop?.held) return platformHoldEnvelope({
|
|
2498
|
+
command: project_create_batch_COMMAND,
|
|
2499
|
+
name: "CreateHeld",
|
|
2500
|
+
body: stop.body,
|
|
2501
|
+
value: {
|
|
2502
|
+
workspace: session.workspace,
|
|
2503
|
+
projects: session.refs,
|
|
2504
|
+
results: rows
|
|
2505
|
+
}
|
|
2506
|
+
});
|
|
2507
|
+
if (0 === created.length && stop?.code === "CLI_PROJECT_CREATE_DISABLED") return fail("CreateClosed", stop.message, "lane-closed", "E_PLATFORM", {
|
|
2508
|
+
hint: laneClosedHint(),
|
|
2509
|
+
value: {
|
|
2510
|
+
workspaceSlug: session.workspace,
|
|
2511
|
+
results: rows
|
|
2512
|
+
}
|
|
2513
|
+
});
|
|
2514
|
+
const nextSteps = [
|
|
2515
|
+
...needLogin.length ? [
|
|
2516
|
+
`extension_auth (action: login, projects: ${quoteList(needLogin)})`
|
|
2517
|
+
] : [],
|
|
2518
|
+
...retry.length ? [
|
|
2519
|
+
`extension_project_create with projects limited to ${quoteList(retry)}, once what refused them is fixed (a new approval; a create list may only name projects that do not exist yet)`
|
|
2520
|
+
] : [],
|
|
2521
|
+
...unconfirmed.length ? [
|
|
2522
|
+
`check ${quoteList(unconfirmed)} in the console at ${consoleBase()} before creating them again`
|
|
2523
|
+
] : [],
|
|
2524
|
+
...loggedIn.length ? [
|
|
2525
|
+
"extension_publish (project: one of the logged-in projects)"
|
|
2526
|
+
] : []
|
|
2527
|
+
];
|
|
2528
|
+
const value = {
|
|
2529
|
+
workspaceSlug: session.workspace,
|
|
2530
|
+
results: rows,
|
|
2531
|
+
counts: {
|
|
2532
|
+
listed: rows.length,
|
|
2533
|
+
created: created.length,
|
|
2534
|
+
loggedIn: loggedIn.length,
|
|
2535
|
+
refused: rows.filter((row)=>"refused" === row.status).length,
|
|
2536
|
+
unconfirmed: unconfirmed.length,
|
|
2537
|
+
notAttempted: rows.filter((row)=>"not-attempted" === row.status).length
|
|
2538
|
+
},
|
|
2539
|
+
...created.length ? {
|
|
2540
|
+
allowance: spendNarration({
|
|
2541
|
+
what: `Creating ${created.length} project${1 === created.length ? "" : "s"}, including each first build,`,
|
|
2542
|
+
body: session.lastBody ?? void 0,
|
|
2543
|
+
api: session.apiBase
|
|
2544
|
+
})
|
|
2545
|
+
} : {},
|
|
2546
|
+
nextSteps
|
|
2547
|
+
};
|
|
2548
|
+
const spent = "The provisioning grant is spent or discarded and was never written to this machine.";
|
|
2549
|
+
if (created.length === rows.length) return envelope_envelope({
|
|
2550
|
+
ok: true,
|
|
2551
|
+
command: project_create_batch_COMMAND,
|
|
2552
|
+
status: "created",
|
|
2553
|
+
value,
|
|
2554
|
+
hint: 0 === needLogin.length ? `All ${rows.length} projects exist, each first build was dispatched, and this machine is signed in to every one of them for 7 days. ${spent} Token-scoped tools take \`project\` to pick one.` : `All ${rows.length} projects exist and each first build was dispatched, but no token was stored for ${needLogin.join(", ")}. ${spent} Sign in to those with one batch login: ${nextSteps[0]}.`,
|
|
2555
|
+
...needLogin.length ? {
|
|
2556
|
+
warnings: [
|
|
2557
|
+
`Created without a stored login: ${needLogin.join(", ")}. Run ${nextSteps[0]}.`
|
|
2558
|
+
]
|
|
2559
|
+
} : {}
|
|
2560
|
+
});
|
|
2561
|
+
return envelope_envelope({
|
|
2562
|
+
ok: false,
|
|
2563
|
+
command: project_create_batch_COMMAND,
|
|
2564
|
+
status: "batch-incomplete",
|
|
2565
|
+
error: {
|
|
2566
|
+
code: "E_PLATFORM",
|
|
2567
|
+
name: "BatchIncomplete",
|
|
2568
|
+
message: `${created.length} of ${rows.length} projects were created. Each project has its own row in results with the platform's code; nothing is retried automatically.${stop ? ` The list stopped at ${stop.code}: ${stop.message}` : ""}`
|
|
2569
|
+
},
|
|
2570
|
+
value,
|
|
2571
|
+
hint: `${spent} ${stop?.code === "RATE_LIMITED" ? `${createRateLimitNote()} ` : ""}Next: ${nextSteps.join("; then ") || "read results"}.`
|
|
2572
|
+
});
|
|
2573
|
+
}
|
|
2574
|
+
async function runSlice(session, deviceCode, installationId) {
|
|
2575
|
+
if (session.busy) return envelope_envelope({
|
|
2576
|
+
ok: true,
|
|
2577
|
+
command: project_create_batch_COMMAND,
|
|
2578
|
+
status: "creating",
|
|
2579
|
+
value: {
|
|
2580
|
+
workspaceSlug: session.workspace,
|
|
2581
|
+
deviceCode,
|
|
2582
|
+
results: rowsInOrder(session)
|
|
2583
|
+
},
|
|
2584
|
+
hint: "Another call with this deviceCode is creating projects right now. Wait for it to answer, then call again with the same deviceCode and arguments if projects are still pending."
|
|
2585
|
+
});
|
|
2586
|
+
session.busy = true;
|
|
2587
|
+
let halted = false;
|
|
2588
|
+
try {
|
|
2589
|
+
const started = Date.now();
|
|
2590
|
+
let attempted = 0;
|
|
2591
|
+
for (const entry of session.entries){
|
|
2592
|
+
if (session.rows.has(entry.ref)) continue;
|
|
2593
|
+
if (session.stop) break;
|
|
2594
|
+
if (Math.floor(Date.now() / 1000) >= session.grantExpiresAt - GRANT_EXPIRY_MARGIN_SECONDS) {
|
|
2595
|
+
session.stop = {
|
|
2596
|
+
code: "TOKEN_EXPIRED",
|
|
2597
|
+
message: "The provisioning grant expired; it lives fifteen minutes."
|
|
2598
|
+
};
|
|
2599
|
+
break;
|
|
2600
|
+
}
|
|
2601
|
+
if (attempted > 0 && Date.now() - started >= SLICE_BUDGET_MS) break;
|
|
2602
|
+
attempted += 1;
|
|
2603
|
+
const outcome = await createOne(session, entry, installationId);
|
|
2604
|
+
session.rows.set(entry.ref, outcome.row);
|
|
2605
|
+
if (outcome.stop) session.stop = outcome.stop;
|
|
2606
|
+
if (outcome.halt) {
|
|
2607
|
+
halted = true;
|
|
2608
|
+
break;
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
} finally{
|
|
2612
|
+
session.busy = false;
|
|
2613
|
+
}
|
|
2614
|
+
const remaining = session.entries.filter((entry)=>!session.rows.has(entry.ref));
|
|
2615
|
+
if (remaining.length > 0 && !session.stop) {
|
|
2616
|
+
const done = session.entries.length - remaining.length;
|
|
2617
|
+
return envelope_envelope({
|
|
2618
|
+
ok: true,
|
|
2619
|
+
command: project_create_batch_COMMAND,
|
|
2620
|
+
status: "creating",
|
|
2621
|
+
value: {
|
|
2622
|
+
workspaceSlug: session.workspace,
|
|
2623
|
+
deviceCode,
|
|
2624
|
+
results: rowsInOrder(session),
|
|
2625
|
+
remaining: remaining.map((entry)=>entry.ref)
|
|
2626
|
+
},
|
|
2627
|
+
hint: `${done} of ${session.entries.length} projects handled so far; ${remaining.length} still to create. Call extension_project_create again with this same deviceCode and the same arguments to continue. No new approval is needed: the grant is held in this server's memory until ${new Date(1000 * session.grantExpiresAt).toISOString()} and is lost if the server restarts.${halted ? " The last request got no answer, so its project is marked unconfirmed and is not retried." : ""}`
|
|
2628
|
+
});
|
|
2629
|
+
}
|
|
2630
|
+
sessions.delete(deviceCode);
|
|
2631
|
+
return finalEnvelope(session);
|
|
2632
|
+
}
|
|
2633
|
+
async function createProjectBatch(args) {
|
|
2634
|
+
const parsed = parseBatchCreateArgs(args);
|
|
2635
|
+
if (!parsed.ok) return fail("BadRequest", parsed.message, "bad-request", "E_BAD_REQUEST", {
|
|
2636
|
+
hint: parsed.hint
|
|
2637
|
+
});
|
|
2638
|
+
const installationId = String(args.installationId || "").trim();
|
|
2639
|
+
if (installationId && !/^\d+$/.test(installationId)) return fail("BadRequest", "installationId is optional, and when given it must be the numeric extension.dev GitHub App installation id. Omit it and the platform resolves it from the approving account.", "bad-request", "E_BAD_REQUEST");
|
|
2640
|
+
const refs = parsed.entries.map((entry)=>entry.ref);
|
|
2641
|
+
const apiCheck = safeApiBase(resolveApiBase(args.api), args.api);
|
|
2642
|
+
if (!apiCheck.ok) return fail("ConfigError", apiCheck.message, "bad-request", "E_BAD_REQUEST");
|
|
2643
|
+
const apiBase = apiCheck.base;
|
|
2644
|
+
sweepSessions(Math.floor(Date.now() / 1000));
|
|
2645
|
+
let deviceCode = String(args.deviceCode || "").trim();
|
|
2646
|
+
const held = deviceCode ? sessions.get(deviceCode) : void 0;
|
|
2647
|
+
if (held) {
|
|
2648
|
+
if (held.apiBase !== apiBase || !sameProjectSet(held.refs, refs)) return fail("BadRequest", `This deviceCode belongs to an approval for ${quoteList(held.refs)}. Call again with that same list, or start a new call without deviceCode for a different one.`, "bad-request", "E_BAD_REQUEST");
|
|
2649
|
+
return runSlice(held, deviceCode, installationId || void 0);
|
|
2650
|
+
}
|
|
2651
|
+
let config;
|
|
2652
|
+
try {
|
|
2653
|
+
config = await fetchLoginConfig(apiBase);
|
|
2654
|
+
} catch (err) {
|
|
2655
|
+
return fail("ConfigError", err?.message || "Could not load login config.", "create-failed", "E_PLATFORM");
|
|
1871
2656
|
}
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
}
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
}
|
|
2657
|
+
if (!config.batch) return fail("CreateBatchUnsupported", BATCH_UNSUPPORTED_MESSAGE, "batch-unsupported", "E_PLATFORM", {
|
|
2658
|
+
value: {
|
|
2659
|
+
projects: refs
|
|
2660
|
+
},
|
|
2661
|
+
hint: "Create the projects one extension_project_create call each (project, repo)."
|
|
2662
|
+
});
|
|
2663
|
+
const createCap = config.batch.createProjectsPerApproval;
|
|
2664
|
+
if (refs.length > createCap) return fail("BadRequest", `projects names ${refs.length} projects to create, and one approval creates at most ${createCap} on this platform.`, "bad-request", "E_BAD_REQUEST", {
|
|
2665
|
+
value: {
|
|
2666
|
+
maxProjects: createCap,
|
|
2667
|
+
listed: refs.length
|
|
2668
|
+
},
|
|
2669
|
+
hint: `${createCapNote(createCap)} ${createRateLimitNote()} Nothing was started and no device code was spent.`
|
|
2670
|
+
});
|
|
2671
|
+
let interval = 5;
|
|
2672
|
+
let budgetMs = RESUME_BUDGET_MS;
|
|
2673
|
+
let start = null;
|
|
2674
|
+
if (!deviceCode) {
|
|
2675
|
+
try {
|
|
2676
|
+
start = await requestDeviceCode({
|
|
2677
|
+
apiBase,
|
|
2678
|
+
path: config.deviceCodeUrl,
|
|
2679
|
+
projects: refs,
|
|
2680
|
+
intent: "create"
|
|
2681
|
+
});
|
|
2682
|
+
} catch (err) {
|
|
2683
|
+
const serverCode = String(err?.serverCode || "");
|
|
2684
|
+
const serverMessage = "string" == typeof err?.serverMessage ? err.serverMessage.trim() : "";
|
|
2685
|
+
if ("CLI_PROJECT_CREATE_DISABLED" === serverCode) return fail("CreateStartError", serverMessage || "Headless project creation is not open on this host yet.", "lane-closed", "E_PLATFORM", {
|
|
2686
|
+
hint: laneClosedHint()
|
|
2687
|
+
});
|
|
2688
|
+
return fail("CreateStartError", String(err?.message || "Could not start the device flow."), "create-failed", "E_PLATFORM", {
|
|
2689
|
+
hint: "No device code was issued and nothing was created. Fix what the platform refused and try the list again, or create the projects one extension_project_create call each (project, repo)."
|
|
2690
|
+
});
|
|
2691
|
+
}
|
|
2692
|
+
deviceCode = start.deviceCode;
|
|
2693
|
+
interval = start.interval;
|
|
2694
|
+
budgetMs = FIRST_CALL_BUDGET_MS;
|
|
1898
2695
|
}
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
2696
|
+
const poll = await pollDeviceGrant({
|
|
2697
|
+
apiBase,
|
|
2698
|
+
path: config.deviceTokenUrl,
|
|
2699
|
+
projects: refs,
|
|
2700
|
+
deviceCode,
|
|
2701
|
+
interval,
|
|
2702
|
+
budgetMs
|
|
2703
|
+
});
|
|
2704
|
+
if (!poll.ok) {
|
|
2705
|
+
if ("pending" === poll.reason) return pendingEnvelope({
|
|
2706
|
+
deviceCode,
|
|
2707
|
+
refs,
|
|
2708
|
+
verificationUri: start?.verificationUri ?? config.verificationUri,
|
|
2709
|
+
userCode: start?.userCode,
|
|
2710
|
+
verificationUriComplete: start?.verificationUriComplete
|
|
2711
|
+
});
|
|
2712
|
+
const code = String(poll.code || "");
|
|
2713
|
+
if ("CLI_PROJECT_CREATE_DISABLED" === code) return fail("CreateClosed", poll.message || "Headless project creation is not open yet.", "lane-closed", "E_PLATFORM", {
|
|
2714
|
+
hint: laneClosedHint()
|
|
2715
|
+
});
|
|
2716
|
+
if ("denied" === poll.reason) return fail("CreateDenied", "Creating the projects was denied at extension.dev/device.", "create-denied", "E_AUTH_DENIED");
|
|
2717
|
+
if ("expired" === poll.reason) return fail("CreateExpired", "The device code expired or was already redeemed. If an earlier call with this deviceCode had started creating projects and this server restarted since, the grant it held is gone: the projects it created still exist.", "create-expired", "E_AUTH_EXPIRED", {
|
|
2718
|
+
hint: `Check which of ${quoteList(refs)} exist in the console at ${consoleBase()}. Sign in to those with extension_auth (action: login, projects), and start a new extension_project_create call naming only the ones that do not exist yet.`
|
|
2719
|
+
});
|
|
2720
|
+
return fail("CreateAuthError", poll.message || "Device authorization failed.", "create-failed", "E_AUTH_FAILED", {
|
|
2721
|
+
value: {
|
|
2722
|
+
...code ? {
|
|
2723
|
+
code
|
|
2724
|
+
} : {},
|
|
2725
|
+
projects: refs
|
|
2726
|
+
}
|
|
2727
|
+
});
|
|
2728
|
+
}
|
|
2729
|
+
const grant = poll.data;
|
|
2730
|
+
const token = String(grant.token || "").trim();
|
|
2731
|
+
const grantWorkspace = String(grant.workspaceSlug || "").trim().toLowerCase();
|
|
2732
|
+
const grantSlugs = Array.isArray(grant.projectSlugs) ? grant.projectSlugs.map((slug)=>String(slug).toLowerCase()) : null;
|
|
2733
|
+
if ("provisioning" !== String(grant.tokenKind || "") || !grantSlugs || grantWorkspace !== parsed.workspace || !sameProjectSet(grantSlugs, parsed.entries.map((entry)=>entry.slug))) return fail("CreateScopeError", `The approval did not come back as a provisioning grant for exactly ${quoteList(refs)}, so it was discarded and nothing was created. Run extension_project_create again.`, "create-failed", "E_AUTH_FAILED", {
|
|
2734
|
+
hint: "If one of these projects already exists, a create list is refused: sign in to existing projects with extension_auth (action: login, projects) and list only new ones here."
|
|
2735
|
+
});
|
|
2736
|
+
const session = {
|
|
2737
|
+
apiBase,
|
|
2738
|
+
workspace: parsed.workspace,
|
|
2739
|
+
refs,
|
|
2740
|
+
entries: parsed.entries,
|
|
2741
|
+
grant: token,
|
|
2742
|
+
grantExpiresAt: Number(grant.expiresAt || 0) || Math.floor(Date.now() / 1000) + 900,
|
|
2743
|
+
rows: new Map(),
|
|
2744
|
+
busy: false,
|
|
2745
|
+
stop: null,
|
|
2746
|
+
lastBody: null,
|
|
2747
|
+
unanswered: 0
|
|
1907
2748
|
};
|
|
2749
|
+
sessions.set(deviceCode, session);
|
|
2750
|
+
return runSlice(session, deviceCode, installationId || void 0);
|
|
1908
2751
|
}
|
|
1909
2752
|
const project_create_COMMAND = "extension_project_create";
|
|
1910
|
-
const
|
|
1911
|
-
const
|
|
2753
|
+
const project_create_FIRST_CALL_BUDGET_MS = 8000;
|
|
2754
|
+
const project_create_RESUME_BUDGET_MS = 22000;
|
|
1912
2755
|
const project_create_schema = {
|
|
1913
2756
|
name: "extension_project_create",
|
|
1914
|
-
description: "Create an extension.dev project for an extension that does not have one yet, without opening the console. Use it right after extension_create and extension_build, once the extension's source is pushed to a GitHub repository, and BEFORE extension_auth: extension_auth can only log in to a project that already exists, and this tool is what brings that project into existence. Ask for nothing but the project slug and the repo; the platform finds the GitHub App installation on the approving account itself, and if there is none it returns a connect link to open. Two-phase, like login: the first call returns a code and a URL where the signed-in workspace owner approves creating exactly this project; call again with the returned deviceCode to finish. The approval mints a provisioning grant that lives minutes, can only create the one named project, and is never stored on this machine. On success the platform creates the project, its mirror repository, and dispatches the first build. Then run extension_auth (action: login) against the new project, and extension_publish to share it.",
|
|
2757
|
+
description: "Create an extension.dev project for an extension that does not have one yet, without opening the console. Use it right after extension_create and extension_build, once the extension's source is pushed to a GitHub repository, and BEFORE extension_auth: extension_auth can only log in to a project that already exists, and this tool is what brings that project into existence. Ask for nothing but the project slug and the repo; the platform finds the GitHub App installation on the approving account itself, and if there is none it returns a connect link to open. Two-phase, like login: the first call returns a code and a URL where the signed-in workspace owner approves creating exactly this project; call again with the returned deviceCode to finish. The approval mints a provisioning grant that lives minutes, can only create the one named project, and is never stored on this machine. On success the platform creates the project, its mirror repository, and dispatches the first build. Then run extension_auth (action: login) against the new project, and extension_publish to share it. To create several projects in one workspace under one approval, pass `projects` instead of `project` and `repo`: the approval page lists every name, each project is created by its own request, and each one's 7-day token is stored as that project's login, so no extension_auth call is needed afterwards. A list takes a few calls to finish: while projects remain the answer is status 'creating' with the same deviceCode to call again, and the grant is held in this server's memory only. One approval creates at most 10 projects, the cap the platform states in its login config, because it creates at most 10 per hour for one approving account; the next 10 can start in a new call once that limit allows. A longer list is refused before any approval is asked for, never split silently, and so is any list on a platform that does not advertise batch onboarding.",
|
|
1915
2758
|
inputSchema: {
|
|
1916
2759
|
type: "object",
|
|
1917
2760
|
properties: {
|
|
@@ -1969,19 +2812,23 @@ const project_create_schema = {
|
|
|
1969
2812
|
type: "object",
|
|
1970
2813
|
description: "Per-browser output directory overrides, for example {\"edge\": \"build/manifestv3\"}. A browser named here wins over `outputDirectory`."
|
|
1971
2814
|
},
|
|
2815
|
+
projects: {
|
|
2816
|
+
type: "array",
|
|
2817
|
+
items: {
|
|
2818
|
+
type: "object"
|
|
2819
|
+
},
|
|
2820
|
+
description: "Create several projects in one workspace under one approval, instead of `project` and `repo`. 1 to 10 entries (the platform's cap per approval; never more than 20), each { project: '<workspace>/<project>', repo: '<owner>/<repo>' }, all in the same workspace, each project named by its exact slug (lowercase letters and digits joined by single dashes, at most 48 characters), none twice and none existing yet. An entry may also carry displayName, description, installCommand, buildCommand, outputDirectory, browsers and outputDirectories for that project alone; the same inputs at the top level are the shared default for every entry that leaves them out. The answer carries one row per project: created and logged in, created with no token (run a batch extension_auth login), refused with the platform's code, or not attempted. A refusal on one project never hides the others."
|
|
2821
|
+
},
|
|
1972
2822
|
deviceCode: {
|
|
1973
2823
|
type: "string",
|
|
1974
|
-
description: "Resume token from the prior call's `deviceCode`; omit on the first call."
|
|
2824
|
+
description: "Resume token from the prior call's `deviceCode`; omit on the first call. A batch returns the same deviceCode until every listed project has an answer."
|
|
1975
2825
|
},
|
|
1976
2826
|
api: API_BASE
|
|
1977
2827
|
},
|
|
1978
|
-
required: [
|
|
1979
|
-
"project",
|
|
1980
|
-
"repo"
|
|
1981
|
-
]
|
|
2828
|
+
required: []
|
|
1982
2829
|
}
|
|
1983
2830
|
};
|
|
1984
|
-
function
|
|
2831
|
+
function project_create_fail(name, message, status, code, hint) {
|
|
1985
2832
|
return envelope_envelope({
|
|
1986
2833
|
ok: false,
|
|
1987
2834
|
command: project_create_COMMAND,
|
|
@@ -1996,10 +2843,10 @@ function fail(name, message, status, code, hint) {
|
|
|
1996
2843
|
} : {}
|
|
1997
2844
|
});
|
|
1998
2845
|
}
|
|
1999
|
-
function
|
|
2846
|
+
function project_create_laneClosedHint() {
|
|
2000
2847
|
return `Create the project in the console at ${consoleBase()} (workspace page, New project), then run extension_auth (action: login) against it. While the public hold is on, headless creation is open only to workspaces on the platform's allowlist (WWW_MCP_ACTION_ALLOWED_WORKSPACES), the same list that already lets publish, submit and promote run headlessly; this workspace is not on it, so the console is the route.`;
|
|
2001
2848
|
}
|
|
2002
|
-
function
|
|
2849
|
+
function project_create_pendingEnvelope(start) {
|
|
2003
2850
|
const complete = String(start.verificationUriComplete || "").trim();
|
|
2004
2851
|
const hasCompleteLink = complete.length > 0 && complete !== start.verificationUri;
|
|
2005
2852
|
const message = hasCompleteLink ? `Open ${complete} and approve creating the project (code ${start.userCode} is pre-filled), then call extension_project_create again with this deviceCode and the same arguments. If the page asks for a code, enter ${start.userCode} at ${start.verificationUri}.` : `Open ${start.verificationUri}, enter code ${start.userCode}, approve creating the project, then call extension_project_create again with this deviceCode and the same arguments.`;
|
|
@@ -2018,79 +2865,26 @@ function pendingEnvelope(start) {
|
|
|
2018
2865
|
hint: message
|
|
2019
2866
|
});
|
|
2020
2867
|
}
|
|
2021
|
-
function buildCreateBody(args) {
|
|
2022
|
-
const installCommand = String(args.installCommand || "npm install").trim();
|
|
2023
|
-
const buildCommand = String(args.buildCommand || "npm run build").trim();
|
|
2024
|
-
const wanted = new Set((Array.isArray(args.browsers) && args.browsers.length ? args.browsers : [
|
|
2025
|
-
"chrome"
|
|
2026
|
-
]).map((name)=>String(name).trim().toLowerCase()));
|
|
2027
|
-
const several = wanted.size > 1;
|
|
2028
|
-
const outputFor = (name)=>{
|
|
2029
|
-
const override = args.outputDirectories?.[name];
|
|
2030
|
-
if ("string" == typeof override && override.trim()) return override.trim();
|
|
2031
|
-
const pattern = String(args.outputDirectory || "").trim();
|
|
2032
|
-
if (pattern.includes("<browser>")) return pattern.replaceAll("<browser>", name);
|
|
2033
|
-
if (pattern && !several) return pattern;
|
|
2034
|
-
return `dist/${name}`;
|
|
2035
|
-
};
|
|
2036
|
-
const browser = (name)=>({
|
|
2037
|
-
enabled: wanted.has(name),
|
|
2038
|
-
installCommand,
|
|
2039
|
-
buildCommand,
|
|
2040
|
-
outputDirectory: outputFor(name)
|
|
2041
|
-
});
|
|
2042
|
-
return {
|
|
2043
|
-
info: {
|
|
2044
|
-
id: "",
|
|
2045
|
-
name: args.projectSlug,
|
|
2046
|
-
displayName: String(args.displayName || args.projectSlug).trim(),
|
|
2047
|
-
description: String(args.description || `Browser extension project for ${args.owner}/${args.repoName}.`).trim()
|
|
2048
|
-
},
|
|
2049
|
-
build: {
|
|
2050
|
-
chrome: browser("chrome"),
|
|
2051
|
-
edge: browser("edge"),
|
|
2052
|
-
firefox: browser("firefox")
|
|
2053
|
-
},
|
|
2054
|
-
deployment: {
|
|
2055
|
-
branch: "",
|
|
2056
|
-
nodeVersion: "",
|
|
2057
|
-
runWhatsNew: false,
|
|
2058
|
-
runExtensionExecutables: false
|
|
2059
|
-
},
|
|
2060
|
-
github: {
|
|
2061
|
-
owner: args.owner,
|
|
2062
|
-
repo: args.repoName,
|
|
2063
|
-
installationId: args.installationId,
|
|
2064
|
-
createdAt: new Date().toISOString(),
|
|
2065
|
-
pullRequestComments: true,
|
|
2066
|
-
commitComments: false
|
|
2067
|
-
},
|
|
2068
|
-
workspaceSlug: args.workspace,
|
|
2069
|
-
createdFrom: {
|
|
2070
|
-
kind: "repository",
|
|
2071
|
-
ref: `${args.owner}/${args.repoName}`
|
|
2072
|
-
}
|
|
2073
|
-
};
|
|
2074
|
-
}
|
|
2075
2868
|
async function project_create_handler(args) {
|
|
2869
|
+
if (void 0 !== args.projects && null !== args.projects) return createProjectBatch(args);
|
|
2076
2870
|
const project = String(args.project || "").trim();
|
|
2077
|
-
if (!/^[^/]+\/[^/]+$/.test(project)) return
|
|
2871
|
+
if (!/^[^/]+\/[^/]+$/.test(project)) return project_create_fail("BadRequest", "project must be in the form '<workspace>/<project>'.", "bad-request", "E_BAD_REQUEST");
|
|
2078
2872
|
const repo = String(args.repo || "").trim();
|
|
2079
|
-
if (!/^[^/]+\/[^/]+$/.test(repo)) return
|
|
2873
|
+
if (!/^[^/]+\/[^/]+$/.test(repo)) return project_create_fail("BadRequest", "repo must be in the form '<owner>/<repo>', a GitHub repository the extension's source is pushed to.", "bad-request", "E_BAD_REQUEST");
|
|
2080
2874
|
const installationId = String(args.installationId || "").trim();
|
|
2081
|
-
if (installationId && !/^\d+$/.test(installationId)) return
|
|
2875
|
+
if (installationId && !/^\d+$/.test(installationId)) return project_create_fail("BadRequest", "installationId is optional, and when given it must be the numeric extension.dev GitHub App installation id. Omit it and the platform resolves it from the approving account.", "bad-request", "E_BAD_REQUEST");
|
|
2082
2876
|
const apiCheck = safeApiBase(resolveApiBase(args.api), args.api);
|
|
2083
|
-
if (!apiCheck.ok) return
|
|
2877
|
+
if (!apiCheck.ok) return project_create_fail("ConfigError", apiCheck.message, "bad-request", "E_BAD_REQUEST");
|
|
2084
2878
|
const apiBase = apiCheck.base;
|
|
2085
2879
|
let config;
|
|
2086
2880
|
try {
|
|
2087
2881
|
config = await fetchLoginConfig(apiBase);
|
|
2088
2882
|
} catch (err) {
|
|
2089
|
-
return
|
|
2883
|
+
return project_create_fail("ConfigError", err?.message || "Could not load login config.", "create-failed", "E_PLATFORM");
|
|
2090
2884
|
}
|
|
2091
2885
|
let deviceCode = String(args.deviceCode || "").trim();
|
|
2092
2886
|
let interval = 5;
|
|
2093
|
-
let budgetMs =
|
|
2887
|
+
let budgetMs = project_create_RESUME_BUDGET_MS;
|
|
2094
2888
|
if (!deviceCode) {
|
|
2095
2889
|
let start;
|
|
2096
2890
|
try {
|
|
@@ -2104,11 +2898,11 @@ async function project_create_handler(args) {
|
|
|
2104
2898
|
const message = err?.message || "Could not start the device flow.";
|
|
2105
2899
|
const laneClosed = /CLI_PROJECT_CREATE_DISABLED|403/.test(String(message));
|
|
2106
2900
|
const serverMessage = "string" == typeof err?.serverMessage ? err.serverMessage.trim() : "";
|
|
2107
|
-
return
|
|
2901
|
+
return project_create_fail("CreateStartError", laneClosed ? serverMessage || `Headless project creation is not open on this host yet. Create the project in the console instead, then run extension_auth (action: login) against it. (${message})` : String(message), laneClosed ? "lane-closed" : "create-failed", "E_PLATFORM", laneClosed ? project_create_laneClosedHint() : void 0);
|
|
2108
2902
|
}
|
|
2109
2903
|
deviceCode = start.deviceCode;
|
|
2110
2904
|
interval = start.interval;
|
|
2111
|
-
budgetMs =
|
|
2905
|
+
budgetMs = project_create_FIRST_CALL_BUDGET_MS;
|
|
2112
2906
|
const early = await pollDeviceGrant({
|
|
2113
2907
|
apiBase,
|
|
2114
2908
|
path: config.deviceTokenUrl,
|
|
@@ -2117,7 +2911,7 @@ async function project_create_handler(args) {
|
|
|
2117
2911
|
interval,
|
|
2118
2912
|
budgetMs
|
|
2119
2913
|
});
|
|
2120
|
-
if (!early.ok && "pending" === early.reason) return
|
|
2914
|
+
if (!early.ok && "pending" === early.reason) return project_create_pendingEnvelope(start);
|
|
2121
2915
|
return finishFromPoll(early, {
|
|
2122
2916
|
apiBase,
|
|
2123
2917
|
project,
|
|
@@ -2154,16 +2948,16 @@ async function finishFromPoll(poll, ctx) {
|
|
|
2154
2948
|
},
|
|
2155
2949
|
hint: `Still waiting for approval at ${ctx.verificationUri}. Approve there, then call extension_project_create again with this same deviceCode.`
|
|
2156
2950
|
});
|
|
2157
|
-
if ("denied" === poll.reason) return
|
|
2158
|
-
if ("expired" === poll.reason) return
|
|
2159
|
-
return
|
|
2951
|
+
if ("denied" === poll.reason) return project_create_fail("CreateDenied", "Creating the project was denied at extension.dev/device.", "create-denied", "E_AUTH_DENIED");
|
|
2952
|
+
if ("expired" === poll.reason) return project_create_fail("CreateExpired", "The device code expired. Run extension_project_create again to restart.", "create-expired", "E_AUTH_EXPIRED");
|
|
2953
|
+
return project_create_fail("CreateAuthError", poll.message || "Device authorization failed.", "create-failed", "E_AUTH_FAILED");
|
|
2160
2954
|
}
|
|
2161
2955
|
const grant = poll.data;
|
|
2162
2956
|
const token = String(grant.token || "").trim();
|
|
2163
2957
|
const workspaceSlug = String(grant.workspaceSlug || "").trim();
|
|
2164
2958
|
const projectSlug = String(grant.projectSlug || "").trim();
|
|
2165
2959
|
const [wantWorkspace = "", wantProject = ""] = ctx.project.split("/");
|
|
2166
|
-
if (workspaceSlug.toLowerCase() !== wantWorkspace.toLowerCase() || projectSlug.toLowerCase() !== wantProject.toLowerCase()) return
|
|
2960
|
+
if (workspaceSlug.toLowerCase() !== wantWorkspace.toLowerCase() || projectSlug.toLowerCase() !== wantProject.toLowerCase()) return project_create_fail("CreateScopeError", `The approval was scoped to ${workspaceSlug}/${projectSlug}, not the requested ${ctx.project}. Nothing was created. Run extension_project_create again with the intended project.`, "create-failed", "E_AUTH_FAILED");
|
|
2167
2961
|
if ("provisioning" !== String(grant.tokenKind || "")) return envelope_envelope({
|
|
2168
2962
|
ok: false,
|
|
2169
2963
|
command: project_create_COMMAND,
|
|
@@ -2174,7 +2968,7 @@ async function finishFromPoll(poll, ctx) {
|
|
|
2174
2968
|
},
|
|
2175
2969
|
hint: `Run extension_auth (action: login) with project '${ctx.project}' instead; the login lane mints the project token this tool deliberately does not.`
|
|
2176
2970
|
});
|
|
2177
|
-
const [owner = "", repoName = ""] = ctx.args.repo.split("/");
|
|
2971
|
+
const [owner = "", repoName = ""] = String(ctx.args.repo || "").split("/");
|
|
2178
2972
|
const body = buildCreateBody({
|
|
2179
2973
|
workspace: wantWorkspace,
|
|
2180
2974
|
projectSlug: wantProject,
|
|
@@ -2202,7 +2996,7 @@ async function finishFromPoll(poll, ctx) {
|
|
|
2202
2996
|
body: JSON.stringify(body)
|
|
2203
2997
|
});
|
|
2204
2998
|
} catch (err) {
|
|
2205
|
-
return
|
|
2999
|
+
return project_create_fail("CreateNetworkError", `Could not reach ${url}: ${err?.message || err}`, "create-failed", "E_NETWORK");
|
|
2206
3000
|
}
|
|
2207
3001
|
const text = await res.text();
|
|
2208
3002
|
let data;
|
|
@@ -2224,7 +3018,7 @@ async function finishFromPoll(poll, ctx) {
|
|
|
2224
3018
|
project: wantProject
|
|
2225
3019
|
}
|
|
2226
3020
|
});
|
|
2227
|
-
if ("CLI_PROJECT_CREATE_DISABLED" === code) return
|
|
3021
|
+
if ("CLI_PROJECT_CREATE_DISABLED" === code) return project_create_fail("CreateClosed", String(data.message || "Headless project creation is not open yet."), "lane-closed", "E_PLATFORM", project_create_laneClosedHint());
|
|
2228
3022
|
const connectUrl = String(data.connectUrl || "").trim();
|
|
2229
3023
|
if ("INSTALLATION_ABSENT" === code || "INSTALLATION_ORG_UNSUPPORTED" === code || "INSTALLATION_AMBIGUOUS" === code) return envelope_envelope({
|
|
2230
3024
|
ok: false,
|
|
@@ -2252,7 +3046,7 @@ async function finishFromPoll(poll, ctx) {
|
|
|
2252
3046
|
},
|
|
2253
3047
|
hint: `Run extension_auth (action: login) with project '${ctx.project}'.`
|
|
2254
3048
|
});
|
|
2255
|
-
return
|
|
3049
|
+
return project_create_fail("CreateError", `create failed (${res.status}): ${String(data.message || data.error || text || "unknown error").slice(0, 500)}`, "create-failed", "E_PLATFORM");
|
|
2256
3050
|
}
|
|
2257
3051
|
const finalWorkspace = String(data.workspaceSlug || wantWorkspace);
|
|
2258
3052
|
const finalProject = String(data.projectSlug || wantProject);
|
|
@@ -3053,7 +3847,7 @@ async function outputFlagRefusalMessage(command, cliName, projectPath) {
|
|
|
3053
3847
|
if (true === verdict.supported) return `${preamble} It reports ${verdict.version}, which is at or above ${verdict.floor}, the release where that flag reached \`extension ${cliName}\`, so the binary being run is not the version it claims to be. ${why} Check the project's node_modules/.bin/extension and reinstall it, rather than upgrading a version that already looks new enough.`;
|
|
3054
3848
|
return `${preamble} Its version could not be read, so the cause cannot be confirmed, but that flag only reached \`extension ${cliName}\` in ${verdict.floor} and a refusal is what an engine below that does. ${why} Check the project's Extension.js install and bring it to ${verdict.floor} or newer.`;
|
|
3055
3849
|
}
|
|
3056
|
-
const
|
|
3850
|
+
const process_manager_sessions = new Map();
|
|
3057
3851
|
const registrationStamps = new Map();
|
|
3058
3852
|
function sessionKey(projectPath, browser) {
|
|
3059
3853
|
return `${node_path.resolve(projectPath)}::${browser}`;
|
|
@@ -3114,7 +3908,7 @@ function registerSession(info) {
|
|
|
3114
3908
|
pid: info.pid,
|
|
3115
3909
|
at
|
|
3116
3910
|
});
|
|
3117
|
-
|
|
3911
|
+
process_manager_sessions.set(key, info);
|
|
3118
3912
|
writeMarkerBestEffort(info, at);
|
|
3119
3913
|
}
|
|
3120
3914
|
function sessionSinceMs(projectPath, browser) {
|
|
@@ -3132,19 +3926,19 @@ function sessionSinceMs(projectPath, browser) {
|
|
|
3132
3926
|
return null;
|
|
3133
3927
|
}
|
|
3134
3928
|
function findSessionInfo(projectPath, browser) {
|
|
3135
|
-
const inMemory =
|
|
3929
|
+
const inMemory = process_manager_sessions.get(sessionKey(projectPath, browser));
|
|
3136
3930
|
if (inMemory) return inMemory;
|
|
3137
3931
|
const resolved = node_path.resolve(projectPath);
|
|
3138
3932
|
for (const marker of listSessionMarkers())if (node_path.resolve(marker.projectPath) === resolved && marker.browser === browser) return marker;
|
|
3139
3933
|
}
|
|
3140
3934
|
function removeSession(projectPath, browser, pid) {
|
|
3141
3935
|
const key = sessionKey(projectPath, browser);
|
|
3142
|
-
if (void 0 !== pid &&
|
|
3143
|
-
|
|
3936
|
+
if (void 0 !== pid && process_manager_sessions.get(key)?.pid !== pid) return;
|
|
3937
|
+
process_manager_sessions.delete(key);
|
|
3144
3938
|
registrationStamps.delete(key);
|
|
3145
3939
|
}
|
|
3146
3940
|
function listSessions() {
|
|
3147
|
-
return Array.from(
|
|
3941
|
+
return Array.from(process_manager_sessions.values());
|
|
3148
3942
|
}
|
|
3149
3943
|
const PROJECT_MANIFEST_FILENAMES = [
|
|
3150
3944
|
"package.json",
|
|
@@ -13567,46 +14361,263 @@ async function rdpCollectConsoleMessages(port, options) {
|
|
|
13567
14361
|
if (!watcherActor) throw new Error("tab descriptor returned no watcher");
|
|
13568
14362
|
const messages = [];
|
|
13569
14363
|
const untap = session.tap((packet)=>{
|
|
13570
|
-
if ("resources-available-array" !== packet.type) return;
|
|
13571
|
-
const array = Array.isArray(packet.array) ? packet.array : [];
|
|
13572
|
-
for (const entry of array){
|
|
13573
|
-
if (!Array.isArray(entry) || entry.length < 2) continue;
|
|
13574
|
-
const [resourceType, resources] = entry;
|
|
13575
|
-
if (Array.isArray(resources)) for (const raw of resources){
|
|
13576
|
-
const resource = raw;
|
|
13577
|
-
if ("console-message" === resourceType) {
|
|
13578
|
-
const args = Array.isArray(resource.arguments) ? resource.arguments : [];
|
|
13579
|
-
messages.push({
|
|
13580
|
-
level: String(resource.level ?? "log"),
|
|
13581
|
-
text: args.map(formatConsoleArg).join(" ")
|
|
13582
|
-
});
|
|
13583
|
-
} else if ("error-message" === resourceType) {
|
|
13584
|
-
const pageError = resource.pageError ?? {};
|
|
13585
|
-
messages.push({
|
|
13586
|
-
level: true === pageError.warning ? "warn" : "error",
|
|
13587
|
-
text: String(pageError.errorMessage ?? "")
|
|
13588
|
-
});
|
|
13589
|
-
}
|
|
13590
|
-
}
|
|
13591
|
-
}
|
|
14364
|
+
if ("resources-available-array" !== packet.type) return;
|
|
14365
|
+
const array = Array.isArray(packet.array) ? packet.array : [];
|
|
14366
|
+
for (const entry of array){
|
|
14367
|
+
if (!Array.isArray(entry) || entry.length < 2) continue;
|
|
14368
|
+
const [resourceType, resources] = entry;
|
|
14369
|
+
if (Array.isArray(resources)) for (const raw of resources){
|
|
14370
|
+
const resource = raw;
|
|
14371
|
+
if ("console-message" === resourceType) {
|
|
14372
|
+
const args = Array.isArray(resource.arguments) ? resource.arguments : [];
|
|
14373
|
+
messages.push({
|
|
14374
|
+
level: String(resource.level ?? "log"),
|
|
14375
|
+
text: args.map(formatConsoleArg).join(" ")
|
|
14376
|
+
});
|
|
14377
|
+
} else if ("error-message" === resourceType) {
|
|
14378
|
+
const pageError = resource.pageError ?? {};
|
|
14379
|
+
messages.push({
|
|
14380
|
+
level: true === pageError.warning ? "warn" : "error",
|
|
14381
|
+
text: String(pageError.errorMessage ?? "")
|
|
14382
|
+
});
|
|
14383
|
+
}
|
|
14384
|
+
}
|
|
14385
|
+
}
|
|
14386
|
+
});
|
|
14387
|
+
try {
|
|
14388
|
+
await session.request(watcherActor, {
|
|
14389
|
+
type: "watchTargets",
|
|
14390
|
+
targetType: "frame"
|
|
14391
|
+
}, timeoutMs);
|
|
14392
|
+
await session.request(watcherActor, {
|
|
14393
|
+
type: "watchResources",
|
|
14394
|
+
resourceTypes: [
|
|
14395
|
+
"console-message",
|
|
14396
|
+
"error-message"
|
|
14397
|
+
]
|
|
14398
|
+
}, timeoutMs);
|
|
14399
|
+
await new Promise((resolve)=>setTimeout(resolve, settleMs));
|
|
14400
|
+
} finally{
|
|
14401
|
+
untap();
|
|
14402
|
+
}
|
|
14403
|
+
return messages;
|
|
14404
|
+
});
|
|
14405
|
+
}
|
|
14406
|
+
const RDP_EVAL_SLOT = "__extensionDevRdpEval";
|
|
14407
|
+
const RDP_FRAME_WAIT_MS = 5000;
|
|
14408
|
+
const RDP_FRAME_POLL_MS = 50;
|
|
14409
|
+
const RDP_RESULT_POLL_MS = 100;
|
|
14410
|
+
function rdpStartExpression(expression, token) {
|
|
14411
|
+
const key = JSON.stringify(token);
|
|
14412
|
+
return `(function () {
|
|
14413
|
+
var store = globalThis.${RDP_EVAL_SLOT} = globalThis.${RDP_EVAL_SLOT} || {};
|
|
14414
|
+
store[${key}] = { state: "pending" };
|
|
14415
|
+
function settle(next) { store[${key}] = next; }
|
|
14416
|
+
function encode(value) {
|
|
14417
|
+
if (value === undefined) return { state: "value" };
|
|
14418
|
+
var json;
|
|
14419
|
+
try { json = JSON.stringify(value); } catch (error) { json = undefined; }
|
|
14420
|
+
return { state: "value", json: json === undefined ? JSON.stringify(String(value)) : json };
|
|
14421
|
+
}
|
|
14422
|
+
function thrown(error) {
|
|
14423
|
+
return { state: "throw", name: (error && error.name) || "EvalError", message: (error && error.message) || String(error) };
|
|
14424
|
+
}
|
|
14425
|
+
try {
|
|
14426
|
+
Promise.resolve((${expression}
|
|
14427
|
+
)).then(function (value) { settle(encode(value)); }, function (error) { settle(thrown(error)); });
|
|
14428
|
+
} catch (error) {
|
|
14429
|
+
settle(thrown(error));
|
|
14430
|
+
}
|
|
14431
|
+
return "started";
|
|
14432
|
+
})()`;
|
|
14433
|
+
}
|
|
14434
|
+
function rdpPollExpression(token) {
|
|
14435
|
+
const key = JSON.stringify(token);
|
|
14436
|
+
return `(function () {
|
|
14437
|
+
var store = globalThis.${RDP_EVAL_SLOT};
|
|
14438
|
+
var entry = (store && store[${key}]) || null;
|
|
14439
|
+
if (entry && entry.state !== "pending") {
|
|
14440
|
+
delete store[${key}];
|
|
14441
|
+
if (Object.keys(store).length === 0) { try { delete globalThis.${RDP_EVAL_SLOT}; } catch (error) {} }
|
|
14442
|
+
}
|
|
14443
|
+
var encoded = JSON.stringify(entry);
|
|
14444
|
+
return encoded;
|
|
14445
|
+
})()`;
|
|
14446
|
+
}
|
|
14447
|
+
function readRdpEvalSlot(raw) {
|
|
14448
|
+
if ("string" != typeof raw) return "lost";
|
|
14449
|
+
let slot;
|
|
14450
|
+
try {
|
|
14451
|
+
slot = JSON.parse(raw);
|
|
14452
|
+
} catch {
|
|
14453
|
+
return "lost";
|
|
14454
|
+
}
|
|
14455
|
+
if (!slot || "object" != typeof slot) return "lost";
|
|
14456
|
+
if ("pending" === slot.state) return "pending";
|
|
14457
|
+
if ("throw" === slot.state) return {
|
|
14458
|
+
ok: false,
|
|
14459
|
+
name: "string" == typeof slot.name && slot.name ? slot.name : "EvalError",
|
|
14460
|
+
message: "string" == typeof slot.message && slot.message ? slot.message : "the expression threw inside the document"
|
|
14461
|
+
};
|
|
14462
|
+
if ("value" !== slot.state) return "lost";
|
|
14463
|
+
if ("string" != typeof slot.json) return {
|
|
14464
|
+
ok: true,
|
|
14465
|
+
value: null
|
|
14466
|
+
};
|
|
14467
|
+
try {
|
|
14468
|
+
return {
|
|
14469
|
+
ok: true,
|
|
14470
|
+
value: JSON.parse(slot.json)
|
|
14471
|
+
};
|
|
14472
|
+
} catch {
|
|
14473
|
+
return {
|
|
14474
|
+
ok: true,
|
|
14475
|
+
value: slot.json
|
|
14476
|
+
};
|
|
14477
|
+
}
|
|
14478
|
+
}
|
|
14479
|
+
function exceptionText(packet) {
|
|
14480
|
+
const message = packet.exceptionMessage;
|
|
14481
|
+
if ("string" == typeof message) return message;
|
|
14482
|
+
const initial = message?.initial;
|
|
14483
|
+
if ("string" == typeof initial) return initial;
|
|
14484
|
+
return true === packet.hasException || void 0 !== packet.exception && null !== packet.exception ? "the expression threw inside the document" : "";
|
|
14485
|
+
}
|
|
14486
|
+
async function readStringResult(session, result, timeoutMs) {
|
|
14487
|
+
if (!result || "object" != typeof result) return result;
|
|
14488
|
+
const grip = result;
|
|
14489
|
+
if ("longString" !== grip.type || "string" != typeof grip.actor) return result;
|
|
14490
|
+
const length = "number" == typeof grip.length ? grip.length : 0;
|
|
14491
|
+
const reply = await session.request(grip.actor, {
|
|
14492
|
+
type: "substring",
|
|
14493
|
+
start: 0,
|
|
14494
|
+
end: length
|
|
14495
|
+
}, timeoutMs);
|
|
14496
|
+
return "string" == typeof reply.substring ? reply.substring : grip.initial;
|
|
14497
|
+
}
|
|
14498
|
+
async function consoleEvaluate(session, consoleActor, text, timeoutMs) {
|
|
14499
|
+
const results = [];
|
|
14500
|
+
let wake = null;
|
|
14501
|
+
const untap = session.tap((packet)=>{
|
|
14502
|
+
if (packet.from !== consoleActor || "evaluationResult" !== packet.type) return;
|
|
14503
|
+
results.push(packet);
|
|
14504
|
+
wake?.();
|
|
14505
|
+
});
|
|
14506
|
+
try {
|
|
14507
|
+
const reply = await session.request(consoleActor, {
|
|
14508
|
+
type: "evaluateJSAsync",
|
|
14509
|
+
text
|
|
14510
|
+
}, timeoutMs);
|
|
14511
|
+
const resultId = String(reply.resultID ?? "");
|
|
14512
|
+
const deadline = Date.now() + timeoutMs;
|
|
14513
|
+
for(;;){
|
|
14514
|
+
const hit = results.find((p)=>String(p.resultID ?? "") === resultId);
|
|
14515
|
+
if (hit) return {
|
|
14516
|
+
result: await readStringResult(session, hit.result, timeoutMs),
|
|
14517
|
+
threw: exceptionText(hit)
|
|
14518
|
+
};
|
|
14519
|
+
const left = deadline - Date.now();
|
|
14520
|
+
if (left <= 0) throw new Error(`Firefox sent no evaluation result within ${timeoutMs}ms`);
|
|
14521
|
+
await new Promise((resolve)=>{
|
|
14522
|
+
const timer = setTimeout(resolve, Math.min(left, 250));
|
|
14523
|
+
wake = ()=>{
|
|
14524
|
+
clearTimeout(timer);
|
|
14525
|
+
resolve();
|
|
14526
|
+
};
|
|
14527
|
+
});
|
|
14528
|
+
wake = null;
|
|
14529
|
+
}
|
|
14530
|
+
} finally{
|
|
14531
|
+
untap();
|
|
14532
|
+
}
|
|
14533
|
+
}
|
|
14534
|
+
function topFrameConsoleActor(frames) {
|
|
14535
|
+
const usable = frames.filter((frame)=>"string" == typeof frame.consoleActor);
|
|
14536
|
+
const top = usable.find((frame)=>true === frame.isTopLevelTarget) ?? usable[0];
|
|
14537
|
+
return top ? String(top.consoleActor) : void 0;
|
|
14538
|
+
}
|
|
14539
|
+
async function rdpEvaluateInTab(port, options) {
|
|
14540
|
+
const timeoutMs = options.timeoutMs ?? 10000;
|
|
14541
|
+
return withSession(port, timeoutMs, async (session)=>{
|
|
14542
|
+
const listed = await session.request("root", {
|
|
14543
|
+
type: "listTabs"
|
|
14544
|
+
}, timeoutMs);
|
|
14545
|
+
const tab = options.select(listed.tabs ?? []);
|
|
14546
|
+
if (!tab?.actor) return {
|
|
14547
|
+
ok: false,
|
|
14548
|
+
name: "TargetNotFound",
|
|
14549
|
+
message: "no open tab matches"
|
|
14550
|
+
};
|
|
14551
|
+
const watcher = await session.request(String(tab.actor), {
|
|
14552
|
+
type: "getWatcher",
|
|
14553
|
+
isServerTargetSwitchingEnabled: true
|
|
14554
|
+
}, timeoutMs);
|
|
14555
|
+
const watcherActor = String(watcher.actor ?? "");
|
|
14556
|
+
if (!watcherActor) return {
|
|
14557
|
+
ok: false,
|
|
14558
|
+
name: "Unsupported",
|
|
14559
|
+
message: "this Firefox build exposes no watcher actor for a tab, so the tab cannot be evaluated over the debugger protocol"
|
|
14560
|
+
};
|
|
14561
|
+
const frames = [];
|
|
14562
|
+
const untap = session.tap((packet)=>{
|
|
14563
|
+
if ("target-available-form" !== packet.type) return;
|
|
14564
|
+
const target = packet.target;
|
|
14565
|
+
if (target && "object" == typeof target) frames.push(target);
|
|
13592
14566
|
});
|
|
13593
14567
|
try {
|
|
13594
14568
|
await session.request(watcherActor, {
|
|
13595
14569
|
type: "watchTargets",
|
|
13596
14570
|
targetType: "frame"
|
|
13597
14571
|
}, timeoutMs);
|
|
13598
|
-
|
|
13599
|
-
|
|
13600
|
-
|
|
13601
|
-
|
|
13602
|
-
|
|
13603
|
-
|
|
13604
|
-
|
|
13605
|
-
|
|
14572
|
+
const frameDeadline = Date.now() + Math.min(timeoutMs, RDP_FRAME_WAIT_MS);
|
|
14573
|
+
let consoleActor = topFrameConsoleActor(frames);
|
|
14574
|
+
while(!consoleActor && Date.now() < frameDeadline){
|
|
14575
|
+
await new Promise((resolve)=>setTimeout(resolve, RDP_FRAME_POLL_MS));
|
|
14576
|
+
consoleActor = topFrameConsoleActor(frames);
|
|
14577
|
+
}
|
|
14578
|
+
if (!consoleActor) return {
|
|
14579
|
+
ok: false,
|
|
14580
|
+
name: "TargetNotFound",
|
|
14581
|
+
message: "the tab announced no document to evaluate in (it may still be loading)"
|
|
14582
|
+
};
|
|
14583
|
+
const token = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
14584
|
+
const started = await consoleEvaluate(session, consoleActor, rdpStartExpression(options.expression, token), timeoutMs);
|
|
14585
|
+
if (started.threw) return {
|
|
14586
|
+
ok: false,
|
|
14587
|
+
name: "EvalError",
|
|
14588
|
+
message: started.threw
|
|
14589
|
+
};
|
|
14590
|
+
const resultDeadline = Date.now() + timeoutMs;
|
|
14591
|
+
for(;;){
|
|
14592
|
+
const polled = await consoleEvaluate(session, consoleActor, rdpPollExpression(token), timeoutMs);
|
|
14593
|
+
const settled = readRdpEvalSlot(polled.result);
|
|
14594
|
+
if ("lost" === settled) return {
|
|
14595
|
+
ok: false,
|
|
14596
|
+
name: "EvalLost",
|
|
14597
|
+
message: "the document reloaded or navigated before the expression settled, so its result is gone"
|
|
14598
|
+
};
|
|
14599
|
+
if ("pending" !== settled) return settled.ok ? {
|
|
14600
|
+
ok: true,
|
|
14601
|
+
value: settled.value,
|
|
14602
|
+
tab: {
|
|
14603
|
+
url: String(tab.url ?? ""),
|
|
14604
|
+
title: String(tab.title ?? "")
|
|
14605
|
+
}
|
|
14606
|
+
} : settled;
|
|
14607
|
+
if (Date.now() >= resultDeadline) return {
|
|
14608
|
+
ok: false,
|
|
14609
|
+
name: "Timeout",
|
|
14610
|
+
message: `the expression did not settle within ${timeoutMs}ms`
|
|
14611
|
+
};
|
|
14612
|
+
await new Promise((resolve)=>setTimeout(resolve, RDP_RESULT_POLL_MS));
|
|
14613
|
+
}
|
|
13606
14614
|
} finally{
|
|
13607
14615
|
untap();
|
|
14616
|
+
session.request(watcherActor, {
|
|
14617
|
+
type: "unwatchTargets",
|
|
14618
|
+
targetType: "frame"
|
|
14619
|
+
}, 1000).catch(()=>{});
|
|
13608
14620
|
}
|
|
13609
|
-
return messages;
|
|
13610
14621
|
});
|
|
13611
14622
|
}
|
|
13612
14623
|
const inspect_schema_schema = {
|
|
@@ -14443,7 +15454,7 @@ function readRelayFrame(value) {
|
|
|
14443
15454
|
}
|
|
14444
15455
|
const eval_schema = {
|
|
14445
15456
|
name: "extension_eval",
|
|
14446
|
-
description: "Evaluate an expression in a running extension context. Start the session with allowEval:true (extension_dev), which writes a 0600 session token. Context defaults to 'background', except on a Chromium MV3 session (the default template) where it defaults to 'page', the active tab, because the MV3 service worker CSP blocks eval; pass context:'background' to target the worker anyway and get that explanation back. For content and page, pass `url` to pick the tab, or omit both `url` and `tab` for the active tab; a numeric `tab` only disambiguates. Extension surfaces (popup, options, sidebar, devtools) and override pages (newtab, history, bookmarks) need no tab id but must already be open: open one with extension_open first, because a closed one returns an explicit error. On a Chromium MV3 session those pages, and context:'page' with a chrome-extension:// url, evaluate over CDP, the inspector path the extension page CSP does not govern; elsewhere they evaluate over the in-bundle relay. Call extension_dom_snapshot with listTabs:true to enumerate {tabId, url, title}.",
|
|
15457
|
+
description: "Evaluate an expression in a running extension context. Start the session with allowEval:true (extension_dev), which writes a 0600 session token. Context defaults to 'background', except on a Chromium MV3 session (the default template) where it defaults to 'page', the active tab, because the MV3 service worker CSP blocks eval; pass context:'background' to target the worker anyway and get that explanation back. For content and page, pass `url` to pick the tab, or omit both `url` and `tab` for the active tab; a numeric `tab` only disambiguates. Extension surfaces (popup, options, sidebar, devtools) and override pages (newtab, history, bookmarks) need no tab id but must already be open: open one with extension_open first, because a closed one returns an explicit error. On a Chromium MV3 session those pages, and context:'page' with a chrome-extension:// url, evaluate over CDP, the inspector path the extension page CSP does not govern; elsewhere they evaluate over the in-bundle relay. On Firefox a document whose content security policy forbids eval (the extension's own pages, or a site's) is evaluated over the debugger protocol instead, which takes one expression; a page inside the extension that is no declared surface (pages/*) is reached the same way by context:'page' and its moz-extension:// url once a tab shows it. Call extension_dom_snapshot with listTabs:true to enumerate {tabId, url, title}.",
|
|
14447
15458
|
inputSchema: {
|
|
14448
15459
|
type: "object",
|
|
14449
15460
|
properties: {
|
|
@@ -14764,6 +15775,7 @@ async function evaluateThroughRelay(args, browser, context) {
|
|
|
14764
15775
|
], args.projectPath, args.timeout, eval_schema.name);
|
|
14765
15776
|
let raw = await run(relaySafeExpression(args.expression, token));
|
|
14766
15777
|
let parsed = tryParseFrame(raw);
|
|
15778
|
+
if (isGeckoFamily(browser) && cspRefusedFrame(parsed)) return evaluatePastSurfaceCsp(args, context, run);
|
|
14767
15779
|
if (!parsed || true !== parsed.ok) return raw;
|
|
14768
15780
|
let frame = readRelayFrame(parsed.value);
|
|
14769
15781
|
if (!frame) return raw;
|
|
@@ -14810,21 +15822,28 @@ async function evaluateThroughRelay(args, browser, context) {
|
|
|
14810
15822
|
return actFrameJson(parsed);
|
|
14811
15823
|
}
|
|
14812
15824
|
const CSP_EVAL_REFUSAL = /blocked by CSP|call to eval|unsafe-eval|Content Security Policy/i;
|
|
15825
|
+
function cspRefusedFrame(parsed) {
|
|
15826
|
+
if (!parsed || false !== parsed.ok) return false;
|
|
15827
|
+
const code = "string" == typeof parsed.error?.code ? parsed.error.code : "";
|
|
15828
|
+
if ("E_CSP_BLOCKS_EVAL" === code) return true;
|
|
15829
|
+
return "E_EVAL" === code && CSP_EVAL_REFUSAL.test(String(parsed.error?.message ?? ""));
|
|
15830
|
+
}
|
|
15831
|
+
function cspRefusalHint(context) {
|
|
15832
|
+
if ("page" === context) return "This page's own Content-Security-Policy forbids eval, so the in-page executor cannot run any expression in it; this is the site's policy, not a fault in the expression. Pass url so the tab is evaluated through the browser's debugger, which the policy does not govern (CDP on Chromium, the debugger protocol on Firefox), or read the page with extension_dom_snapshot or extension_inspect.";
|
|
15833
|
+
if ("content" === context) return "The extension's content_security_policy forbids eval in its content-script world on this engine, so no string runs there; this is the extension's policy, not a fault in the expression. Evaluate the page itself with context: \"page\" and a url, or read the DOM the content script sees with extension_dom_snapshot or extension_inspect.";
|
|
15834
|
+
return `The extension's content_security_policy (or the MV3 default) forbids eval in its own ${context ?? "background"} context, so the in-page executor cannot run any expression there; this is the extension's policy, not a fault in the expression. Read the page instead with extension_dom_snapshot or extension_inspect, evaluate a web page with context: \"page\" and a url, or read the extension's console with extension_logs. On a Chromium MV3 session the extension's own pages evaluate over CDP, which the page policy does not govern. On Firefox, Extension.js 4.1.31 and later evaluate them over the debugger protocol instead: if this session runs an older engine, upgrade the project's extension dependency and restart it.`;
|
|
15835
|
+
}
|
|
14813
15836
|
function explainCspRefusal(raw, context) {
|
|
14814
15837
|
const parsed = tryParseFrame(raw);
|
|
14815
|
-
if (!parsed ||
|
|
14816
|
-
const code = "string" == typeof parsed.error?.code ? parsed.error.code : "";
|
|
14817
|
-
const message = String(parsed.error?.message ?? "");
|
|
14818
|
-
if ("E_EVAL" !== code || !CSP_EVAL_REFUSAL.test(message)) return null;
|
|
15838
|
+
if (!parsed || !cspRefusedFrame(parsed)) return null;
|
|
14819
15839
|
parsed.error.name = "CspBlocksEval";
|
|
14820
|
-
parsed.hint =
|
|
15840
|
+
parsed.hint = cspRefusalHint(context);
|
|
14821
15841
|
if ("string" == typeof parsed.error.hint) delete parsed.error.hint;
|
|
14822
15842
|
return actFrameJson(parsed);
|
|
14823
15843
|
}
|
|
14824
15844
|
const NO_SCRIPTING_API = /scripting is not available/i;
|
|
14825
15845
|
function pageEvalRefusedByCsp(parsed) {
|
|
14826
|
-
|
|
14827
|
-
return "E_EVAL" === code && CSP_EVAL_REFUSAL.test(String(parsed.error?.message ?? ""));
|
|
15846
|
+
return cspRefusedFrame(parsed);
|
|
14828
15847
|
}
|
|
14829
15848
|
function isSingleExpression(source) {
|
|
14830
15849
|
try {
|
|
@@ -14834,6 +15853,110 @@ function isSingleExpression(source) {
|
|
|
14834
15853
|
return false;
|
|
14835
15854
|
}
|
|
14836
15855
|
}
|
|
15856
|
+
function notOneExpression() {
|
|
15857
|
+
return envelope_envelope({
|
|
15858
|
+
ok: false,
|
|
15859
|
+
command: eval_schema.name,
|
|
15860
|
+
status: "bad-request",
|
|
15861
|
+
error: {
|
|
15862
|
+
code: "E_BAD_REQUEST",
|
|
15863
|
+
name: "NotOneExpression",
|
|
15864
|
+
message: "This document's content security policy forbids eval, so Firefox evaluates it over the debugger protocol, which takes one expression, and this input is a statement list."
|
|
15865
|
+
},
|
|
15866
|
+
hint: "Pass one expression. Wrap statements in a function that returns the value, for example (() => { const a = 20; return a + 1; })()."
|
|
15867
|
+
});
|
|
15868
|
+
}
|
|
15869
|
+
async function evaluatePastSurfaceCsp(args, context, run) {
|
|
15870
|
+
if (!isSingleExpression(args.expression)) return notOneExpression();
|
|
15871
|
+
const direct = await run(args.expression);
|
|
15872
|
+
const frame = tryParseFrame(direct);
|
|
15873
|
+
if (!frame) return direct;
|
|
15874
|
+
if (cspRefusedFrame(frame)) return explainCspRefusal(direct, context) ?? direct;
|
|
15875
|
+
if (true === frame.ok && void 0 === frame.value) {
|
|
15876
|
+
frame.value = null;
|
|
15877
|
+
return actFrameJson(frame);
|
|
15878
|
+
}
|
|
15879
|
+
return direct;
|
|
15880
|
+
}
|
|
15881
|
+
const sameDocumentUrl = (a, b)=>String(a ?? "").replace(/[?#].*$/, "") === b.replace(/[?#].*$/, "");
|
|
15882
|
+
function tabByUrl(tabs, url) {
|
|
15883
|
+
const exact = tabs.filter((tab)=>sameDocumentUrl(tab.url, url));
|
|
15884
|
+
const covered = exact.length ? exact : tabs.filter((tab)=>matchPatternCovers(url, String(tab.url ?? "")));
|
|
15885
|
+
const candidates = covered.length ? covered : tabs.filter((tab)=>String(tab.url ?? "").includes(url));
|
|
15886
|
+
return candidates.find((tab)=>true === tab.selected) ?? candidates[0];
|
|
15887
|
+
}
|
|
15888
|
+
async function evaluateInGeckoTab(args, browser, select, missing) {
|
|
15889
|
+
const resolved = await resolveRdpPort(args.projectPath, browser, {
|
|
15890
|
+
waitMs: 3000,
|
|
15891
|
+
graceMs: 1000
|
|
15892
|
+
});
|
|
15893
|
+
if (!resolved) return null;
|
|
15894
|
+
let outcome;
|
|
15895
|
+
try {
|
|
15896
|
+
outcome = await rdpEvaluateInTab(resolved.port, {
|
|
15897
|
+
select,
|
|
15898
|
+
expression: args.expression,
|
|
15899
|
+
timeoutMs: args.timeout ?? 10000
|
|
15900
|
+
});
|
|
15901
|
+
} catch {
|
|
15902
|
+
return null;
|
|
15903
|
+
}
|
|
15904
|
+
if (outcome.ok) return envelope_envelope({
|
|
15905
|
+
ok: true,
|
|
15906
|
+
command: eval_schema.name,
|
|
15907
|
+
status: "ok",
|
|
15908
|
+
value: void 0 === outcome.value ? null : outcome.value
|
|
15909
|
+
});
|
|
15910
|
+
if ("TargetNotFound" === outcome.name) return envelope_envelope({
|
|
15911
|
+
ok: false,
|
|
15912
|
+
command: eval_schema.name,
|
|
15913
|
+
status: "no-matching-target",
|
|
15914
|
+
error: {
|
|
15915
|
+
code: "E_NO_MATCHING_TARGET",
|
|
15916
|
+
name: "NoMatchingTarget",
|
|
15917
|
+
message: missing
|
|
15918
|
+
},
|
|
15919
|
+
hint: "Open the page first with extension_open and its url, then retry; extension_dom_snapshot with listTabs: true lists what is open."
|
|
15920
|
+
});
|
|
15921
|
+
if ("Timeout" === outcome.name) return envelope_envelope({
|
|
15922
|
+
ok: false,
|
|
15923
|
+
command: eval_schema.name,
|
|
15924
|
+
status: "eval-pending",
|
|
15925
|
+
error: {
|
|
15926
|
+
code: "E_WAIT_TIMEOUT",
|
|
15927
|
+
name: "EvalPending",
|
|
15928
|
+
message: outcome.message
|
|
15929
|
+
},
|
|
15930
|
+
hint: "The expression returned a promise that is still pending; pass a larger timeout to wait for it."
|
|
15931
|
+
});
|
|
15932
|
+
return envelope_envelope({
|
|
15933
|
+
ok: false,
|
|
15934
|
+
command: eval_schema.name,
|
|
15935
|
+
status: "eval-failed",
|
|
15936
|
+
error: {
|
|
15937
|
+
code: "E_EVAL",
|
|
15938
|
+
name: outcome.name,
|
|
15939
|
+
message: outcome.message
|
|
15940
|
+
},
|
|
15941
|
+
hint: "The expression threw inside the document. It ran over the debugger protocol, which the document's content security policy does not govern, so this is the expression's own error."
|
|
15942
|
+
});
|
|
15943
|
+
}
|
|
15944
|
+
function backgroundRouteRefusedStatements(args, browser, context, raw) {
|
|
15945
|
+
if (!isGeckoFamily(browser) || (context ?? "background") !== "background") return false;
|
|
15946
|
+
const failed = tryParseFrame(raw);
|
|
15947
|
+
if (!failed || false !== failed.ok) return false;
|
|
15948
|
+
if (failed.error?.name !== "Unavailable") return false;
|
|
15949
|
+
if (!/SyntaxError/.test(String(failed.error?.message ?? ""))) return false;
|
|
15950
|
+
return !isSingleExpression(args.expression);
|
|
15951
|
+
}
|
|
15952
|
+
async function evaluatePagePastSiteCsp(args, browser, context, raw) {
|
|
15953
|
+
if ("page" !== context || !isGeckoFamily(browser)) return null;
|
|
15954
|
+
if (!cspRefusedFrame(tryParseFrame(raw))) return null;
|
|
15955
|
+
if (!args.url && null != args.tab) return null;
|
|
15956
|
+
if (!isSingleExpression(args.expression)) return notOneExpression();
|
|
15957
|
+
const url = args.url;
|
|
15958
|
+
return evaluateInGeckoTab(args, browser, url ? (tabs)=>tabByUrl(tabs, url) : (tabs)=>tabs.find((tab)=>true === tab.selected), url ? `No open tab matches url: ${url}` : "No tab is selected in the dev browser.");
|
|
15959
|
+
}
|
|
14837
15960
|
async function evaluateThroughExecuteScript(args, browser, context, raw) {
|
|
14838
15961
|
if ("page" !== context && "content" !== context) return null;
|
|
14839
15962
|
if (isChromiumFamily(browser)) return null;
|
|
@@ -14928,6 +16051,12 @@ async function eval_handler(args) {
|
|
|
14928
16051
|
}
|
|
14929
16052
|
return raw;
|
|
14930
16053
|
}
|
|
16054
|
+
if (isGeckoFamily(browser)) {
|
|
16055
|
+
if (!isSingleExpression(args.expression)) return notOneExpression();
|
|
16056
|
+
const url = args.url;
|
|
16057
|
+
const overProtocol = await evaluateInGeckoTab(args, browser, (tabs)=>tabs.find((tab)=>sameDocumentUrl(tab.url, url)), `No open tab shows ${url}.`);
|
|
16058
|
+
if (null !== overProtocol) return overProtocol;
|
|
16059
|
+
}
|
|
14931
16060
|
const declared = declaredSurfaces(args.projectPath, browser) ?? [];
|
|
14932
16061
|
return envelope_envelope({
|
|
14933
16062
|
ok: false,
|
|
@@ -14955,6 +16084,9 @@ async function eval_handler(args) {
|
|
|
14955
16084
|
], args.projectPath, args.timeout, eval_schema.name);
|
|
14956
16085
|
const mv2Fallback = await evaluateThroughExecuteScript(args, browser, context, raw);
|
|
14957
16086
|
if (null !== mv2Fallback) return mv2Fallback;
|
|
16087
|
+
const pastSiteCsp = await evaluatePagePastSiteCsp(args, browser, context, raw);
|
|
16088
|
+
if (null !== pastSiteCsp) return pastSiteCsp;
|
|
16089
|
+
if (backgroundRouteRefusedStatements(args, browser, context, raw)) return notOneExpression();
|
|
14958
16090
|
const cspRefusal = explainCspRefusal(raw, context);
|
|
14959
16091
|
if (null !== cspRefusal) return cspRefusal;
|
|
14960
16092
|
const trustedTypes = tryParseFrame(raw);
|
|
@@ -15191,6 +16323,72 @@ const dom_snapshot_schema = {
|
|
|
15191
16323
|
]
|
|
15192
16324
|
}
|
|
15193
16325
|
};
|
|
16326
|
+
const GECKO_EXTENSION_URL = /^moz-extension:\/\//;
|
|
16327
|
+
const dom_snapshot_sameDocumentUrl = (a, b)=>String(a ?? "").replace(/[?#].*$/, "") === b.replace(/[?#].*$/, "");
|
|
16328
|
+
function protocolSnapshotExpression(wantHtml, maxBytes) {
|
|
16329
|
+
return `(function () {
|
|
16330
|
+
var doc = document;
|
|
16331
|
+
function countShadow(root) {
|
|
16332
|
+
var n = 0, els = root.querySelectorAll("*");
|
|
16333
|
+
for (var i = 0; i < els.length; i++) { if (els[i].shadowRoot) { n++; n += countShadow(els[i].shadowRoot); } }
|
|
16334
|
+
return n;
|
|
16335
|
+
}
|
|
16336
|
+
var html = doc.documentElement.outerHTML;
|
|
16337
|
+
var snap = {
|
|
16338
|
+
context: "page",
|
|
16339
|
+
url: String(location.href),
|
|
16340
|
+
title: doc.title,
|
|
16341
|
+
summary: {
|
|
16342
|
+
htmlLength: html.length,
|
|
16343
|
+
scriptCount: doc.querySelectorAll("script").length,
|
|
16344
|
+
styleCount: doc.querySelectorAll("style,link[rel=stylesheet]").length,
|
|
16345
|
+
extensionRootCount: doc.querySelectorAll("#extension-root,[data-extension-root]").length,
|
|
16346
|
+
openShadowRoots: countShadow(doc),
|
|
16347
|
+
bodyChildCount: doc.body ? doc.body.children.length : 0
|
|
16348
|
+
}
|
|
16349
|
+
};
|
|
16350
|
+
if (${wantHtml ? "true" : "false"}) {
|
|
16351
|
+
var cap = ${Math.max(0, Math.floor(maxBytes))};
|
|
16352
|
+
if (cap > 0 && html.length > cap) { snap.html = html.slice(0, cap); snap.htmlTruncated = true; }
|
|
16353
|
+
else { snap.html = html; }
|
|
16354
|
+
}
|
|
16355
|
+
return snap;
|
|
16356
|
+
})()`;
|
|
16357
|
+
}
|
|
16358
|
+
async function snapshotExtensionPageOverProtocol(args, browser, url, raw, consoleAsked) {
|
|
16359
|
+
if (!url || !isGeckoFamily(browser) || !GECKO_EXTENSION_URL.test(url)) return null;
|
|
16360
|
+
try {
|
|
16361
|
+
const refused = JSON.parse(raw);
|
|
16362
|
+
if (!refused || false !== refused.ok) return null;
|
|
16363
|
+
} catch {
|
|
16364
|
+
return null;
|
|
16365
|
+
}
|
|
16366
|
+
const resolved = await resolveRdpPort(args.projectPath, browser, {
|
|
16367
|
+
waitMs: 3000,
|
|
16368
|
+
graceMs: 1000
|
|
16369
|
+
});
|
|
16370
|
+
if (!resolved) return null;
|
|
16371
|
+
const wantHtml = args.include?.includes("html") === true;
|
|
16372
|
+
try {
|
|
16373
|
+
const outcome = await rdpEvaluateInTab(resolved.port, {
|
|
16374
|
+
select: (tabs)=>tabs.find((tab)=>dom_snapshot_sameDocumentUrl(tab.url, url)),
|
|
16375
|
+
expression: protocolSnapshotExpression(wantHtml, args.maxBytes ?? 262144),
|
|
16376
|
+
timeoutMs: args.timeout ?? 10000
|
|
16377
|
+
});
|
|
16378
|
+
if (!outcome.ok) return null;
|
|
16379
|
+
return envelope_envelope({
|
|
16380
|
+
ok: true,
|
|
16381
|
+
command: dom_snapshot_schema.name,
|
|
16382
|
+
status: "ok",
|
|
16383
|
+
value: outcome.value,
|
|
16384
|
+
warnings: [
|
|
16385
|
+
`${url} is a page inside the extension, which Firefox lets no script be injected into, so this snapshot was read over the debugger protocol from the tab showing it.` + (consoleAsked ? " Console lines are not collected on this path; read them with extension_logs." : "")
|
|
16386
|
+
]
|
|
16387
|
+
});
|
|
16388
|
+
} catch {
|
|
16389
|
+
return null;
|
|
16390
|
+
}
|
|
16391
|
+
}
|
|
15194
16392
|
async function cdpPortOrError(projectPath, browser, feature) {
|
|
15195
16393
|
if (!isChromiumFamily(browser)) return {
|
|
15196
16394
|
error: envelope_envelope({
|
|
@@ -15433,7 +16631,10 @@ async function dom_snapshot_handler(args) {
|
|
|
15433
16631
|
if (null != withConsole) cli.push("--with-console", String(withConsole));
|
|
15434
16632
|
cli.push("--browser", resolveSessionBrowser(args.projectPath, args.browser).browser);
|
|
15435
16633
|
if (null != args.timeout) cli.push("--timeout", String(args.timeout));
|
|
15436
|
-
const
|
|
16634
|
+
const sessionBrowser = resolveSessionBrowser(args.projectPath, args.browser).browser;
|
|
16635
|
+
const asked = await runActVerb(cli, args.projectPath, args.timeout, dom_snapshot_schema.name);
|
|
16636
|
+
const overProtocol = await snapshotExtensionPageOverProtocol(args, sessionBrowser, targetUrl ?? ("string" == typeof resolvedTarget?.url ? resolvedTarget.url : void 0), asked, null != withConsole);
|
|
16637
|
+
const raw = overProtocol ?? asked;
|
|
15437
16638
|
if (!resolvedTarget) return raw;
|
|
15438
16639
|
try {
|
|
15439
16640
|
const parsed = JSON.parse(raw);
|
|
@@ -15532,7 +16733,7 @@ async function publish_handler(args) {
|
|
|
15532
16733
|
let note = null;
|
|
15533
16734
|
if (null != args.ttlHours && "public" === data.visibility) note = "ttlHours was ignored: this is a public project, whose share URL is its canonical public page.";
|
|
15534
16735
|
let buildNote = null;
|
|
15535
|
-
const ref =
|
|
16736
|
+
const ref = loginProjectRef(args.project);
|
|
15536
16737
|
if (ref) {
|
|
15537
16738
|
const buildsUrl = registryFileUrl(ref, "builds/index.json");
|
|
15538
16739
|
const buildsRes = await fetchRegistryJson(buildsUrl, fetch, {
|
|
@@ -15736,7 +16937,7 @@ async function release_promote_handler(args) {
|
|
|
15736
16937
|
const code = "string" == typeof data?.code ? data.code : void 0;
|
|
15737
16938
|
const enrich = {};
|
|
15738
16939
|
let hint = "";
|
|
15739
|
-
const ref =
|
|
16940
|
+
const ref = loginProjectRef(args.project);
|
|
15740
16941
|
if (404 === res.status || "UNKNOWN_BUILD" === code) {
|
|
15741
16942
|
enrich.buildsPageUrl = consoleProjectUrl(ref, "builds", args.api);
|
|
15742
16943
|
hint = "Run extension_release_status to see this project's channels, their promoted shas, and recent builds.";
|
|
@@ -15772,7 +16973,7 @@ async function release_promote_handler(args) {
|
|
|
15772
16973
|
hint
|
|
15773
16974
|
});
|
|
15774
16975
|
}
|
|
15775
|
-
const promotedRef =
|
|
16976
|
+
const promotedRef = loginProjectRef(args.project);
|
|
15776
16977
|
const publicChannelUrl = userlandProjectUrl(promotedRef, UserlandProjectPage.channel(channel), args.api);
|
|
15777
16978
|
const publicBuildUrl = userlandProjectUrl(promotedRef, UserlandProjectPage.build(buildId), args.api);
|
|
15778
16979
|
const allowance = spendNarration({
|
|
@@ -16005,7 +17206,7 @@ async function readStores(args) {
|
|
|
16005
17206
|
const healthUrl = registryFileUrl(ref, "stores/health.json");
|
|
16006
17207
|
const statusUrl = registryFileUrl(ref, "stores/status.json");
|
|
16007
17208
|
const submissionsUrl = registryFileUrl(ref, "stores/submissions.json");
|
|
16008
|
-
const consoleStoresUrl = consoleProjectUrl(ref,
|
|
17209
|
+
const consoleStoresUrl = consoleProjectUrl(ref, ConsoleProjectPage.submissions, args.api);
|
|
16009
17210
|
const [healthRes, statusRes, submissionsRes] = await Promise.all([
|
|
16010
17211
|
fetchRegistryJson(healthUrl, fetch, {
|
|
16011
17212
|
ref,
|
|
@@ -16035,7 +17236,7 @@ async function readStores(args) {
|
|
|
16035
17236
|
project: ref.project
|
|
16036
17237
|
}
|
|
16037
17238
|
});
|
|
16038
|
-
if (!healthRes.ok && !statusRes.ok && !submissionsRes.ok) return store_status_fail("StoreStatusNotFound", `No store data on the registry for ${ref.workspace}/${ref.project} (${healthRes.message}). The project may have no stores configured yet, be private (private registry data needs a share token), or the workspace/project slugs may be wrong. Configure stores at ${consoleStoresUrl}/new; the console
|
|
17239
|
+
if (!healthRes.ok && !statusRes.ok && !submissionsRes.ok) return store_status_fail("StoreStatusNotFound", `No store data on the registry for ${ref.workspace}/${ref.project} (${healthRes.message}). The project may have no stores configured yet, be private (private registry data needs a share token), or the workspace/project slugs may be wrong. Configure stores at ${consoleStoresUrl}/new; the console Submissions page is the authoritative view: ${consoleStoresUrl}`, "unavailable", "E_PLATFORM", {
|
|
16039
17240
|
workspace: ref.workspace,
|
|
16040
17241
|
project: ref.project,
|
|
16041
17242
|
registryUrls: {
|
|
@@ -16446,8 +17647,8 @@ async function submit_handler(args) {
|
|
|
16446
17647
|
let channelNote = null;
|
|
16447
17648
|
let statusNote = null;
|
|
16448
17649
|
if (dryRun) {
|
|
16449
|
-
const ref =
|
|
16450
|
-
const consoleStoresUrl = consoleProjectUrl(ref,
|
|
17650
|
+
const ref = loginProjectRef(args.project);
|
|
17651
|
+
const consoleStoresUrl = consoleProjectUrl(ref, ConsoleProjectPage.submissions, args.api);
|
|
16451
17652
|
const storeModeNote = `Store publish mode (draft / skip-publish / live) is not readable with the CLI token, so it cannot be verified from here; check per-store settings at ${consoleStoresUrl}.`;
|
|
16452
17653
|
let health = null;
|
|
16453
17654
|
let healthUnreadable = null;
|
|
@@ -17041,7 +18242,7 @@ async function add_feature_handler(args) {
|
|
|
17041
18242
|
const login_FIRST_CALL_BUDGET_MS = 8000;
|
|
17042
18243
|
const login_RESUME_BUDGET_MS = 22000;
|
|
17043
18244
|
const PENDING_TTL_NOTE = "Once authorized, the minted token lives at most 7 days (server-enforced); CI must re-mint before expiry (console: project settings -> Access tokens).";
|
|
17044
|
-
function login_fail(name, message, status, code) {
|
|
18245
|
+
function login_fail(name, message, status, code, extra) {
|
|
17045
18246
|
return envelope_envelope({
|
|
17046
18247
|
ok: false,
|
|
17047
18248
|
command: "extension_auth",
|
|
@@ -17050,7 +18251,13 @@ function login_fail(name, message, status, code) {
|
|
|
17050
18251
|
code,
|
|
17051
18252
|
name,
|
|
17052
18253
|
message
|
|
17053
|
-
}
|
|
18254
|
+
},
|
|
18255
|
+
...extra?.value ? {
|
|
18256
|
+
value: extra.value
|
|
18257
|
+
} : {},
|
|
18258
|
+
...extra?.hint ? {
|
|
18259
|
+
hint: extra.hint
|
|
18260
|
+
} : {}
|
|
17054
18261
|
});
|
|
17055
18262
|
}
|
|
17056
18263
|
function success(creds) {
|
|
@@ -17166,6 +18373,156 @@ async function loginToProject(args) {
|
|
|
17166
18373
|
verificationUriComplete: start.verificationUriComplete
|
|
17167
18374
|
});
|
|
17168
18375
|
}
|
|
18376
|
+
const BATCH_FINAL_POLL_NOTE = "The platform records one token per project before it answers, so the call that completes a batch login can take up to a minute. Let it finish: the device code is spent the moment minting starts, and a call abandoned midway cannot be resumed.";
|
|
18377
|
+
function batchPending(args) {
|
|
18378
|
+
const complete = String(args.verificationUriComplete || "").trim();
|
|
18379
|
+
const hasCompleteLink = complete.length > 0 && complete !== args.verificationUri;
|
|
18380
|
+
const again = "then call extension_auth (action: login) again with this deviceCode and the same projects";
|
|
18381
|
+
const hint = args.userCode ? hasCompleteLink ? `Open ${complete} and approve (code ${args.userCode} is pre-filled). The page lists all ${args.projects.length} projects this one approval signs in to; ${again}. If the page asks for a code, enter ${args.userCode} at ${args.verificationUri}.` : `Open ${args.verificationUri} and enter code ${args.userCode}. The page lists all ${args.projects.length} projects this one approval signs in to; ${again}.` : `Still waiting for authorization. The link and code from the previous response are still valid: approve at ${args.verificationUri}, ${again}.`;
|
|
18382
|
+
return envelope_envelope({
|
|
18383
|
+
ok: true,
|
|
18384
|
+
command: "extension_auth",
|
|
18385
|
+
status: "authorization-pending",
|
|
18386
|
+
value: {
|
|
18387
|
+
...args.userCode ? {
|
|
18388
|
+
userCode: args.userCode
|
|
18389
|
+
} : {},
|
|
18390
|
+
verificationUri: args.verificationUri,
|
|
18391
|
+
...args.userCode && hasCompleteLink ? {
|
|
18392
|
+
verificationUriComplete: complete
|
|
18393
|
+
} : {},
|
|
18394
|
+
deviceCode: args.deviceCode,
|
|
18395
|
+
projects: args.projects,
|
|
18396
|
+
legacyStatus: "authorization_pending"
|
|
18397
|
+
},
|
|
18398
|
+
hint,
|
|
18399
|
+
warnings: [
|
|
18400
|
+
PENDING_TTL_NOTE,
|
|
18401
|
+
BATCH_FINAL_POLL_NOTE
|
|
18402
|
+
]
|
|
18403
|
+
});
|
|
18404
|
+
}
|
|
18405
|
+
function batchRefusal(poll, projects) {
|
|
18406
|
+
if ("expired" === poll.reason) return login_fail("LoginExpired", "The device code expired or was already spent. Run extension_auth (action: login) again with the same projects to restart.", "login-expired", "E_AUTH_EXPIRED");
|
|
18407
|
+
const code = String(poll.code || "");
|
|
18408
|
+
if ("PROJECT_NOT_FOUND" === code) {
|
|
18409
|
+
const missing = Array.isArray(poll.body?.missingProjects) ? (poll.body?.missingProjects).map(String) : [];
|
|
18410
|
+
return login_fail("LoginProjectNotFound", poll.message || "At least one listed project does not exist, so no token was minted for any of them.", "login-failed", "E_AUTH_FAILED", {
|
|
18411
|
+
value: {
|
|
18412
|
+
code,
|
|
18413
|
+
missingProjects: missing,
|
|
18414
|
+
projects
|
|
18415
|
+
},
|
|
18416
|
+
hint: `A batch login is all or nothing and covers only projects that already exist. ${missing.length ? `Drop or fix ${missing.join(", ")} and run extension_auth (action: login) again with the rest` : "Check each name against the console and run extension_auth (action: login) again"}; create missing projects with extension_project_create or at ${consoleBase()}.`
|
|
18417
|
+
});
|
|
18418
|
+
}
|
|
18419
|
+
if ("MEMBERSHIP_REVOKED" === code) return login_fail("LoginMembershipRevoked", poll.message || "The member who approved this login is no longer in the workspace, so no token was minted.", "login-failed", "E_AUTH_FAILED", {
|
|
18420
|
+
value: {
|
|
18421
|
+
code,
|
|
18422
|
+
projects
|
|
18423
|
+
}
|
|
18424
|
+
});
|
|
18425
|
+
if ("denied" === poll.reason) return login_fail("LoginDenied", "Authorization was denied at extension.dev/device.", "login-denied", "E_AUTH_DENIED");
|
|
18426
|
+
return login_fail("LoginError", poll.message || "Device login failed.", "login-failed", "E_AUTH_FAILED", {
|
|
18427
|
+
value: {
|
|
18428
|
+
...code ? {
|
|
18429
|
+
code
|
|
18430
|
+
} : {},
|
|
18431
|
+
projects
|
|
18432
|
+
},
|
|
18433
|
+
hint: "No token from this batch was stored. Run extension_auth (action: login) again with the same projects; a batch that was refused or failed midway is closed on the platform and needs a fresh approval."
|
|
18434
|
+
});
|
|
18435
|
+
}
|
|
18436
|
+
async function loginToProjects(args) {
|
|
18437
|
+
const parsed = parseProjectBatch(args.projects);
|
|
18438
|
+
if (!parsed.ok) return login_fail("BadRequest", parsed.message, "bad-request", "E_BAD_REQUEST");
|
|
18439
|
+
const projects = parsed.batch.refs;
|
|
18440
|
+
const apiCheck = safeApiBase(resolveApiBase(args.api), args.api);
|
|
18441
|
+
if (!apiCheck.ok) return login_fail("LoginConfigError", apiCheck.message, "login-failed", "E_AUTH_FAILED");
|
|
18442
|
+
const apiBase = apiCheck.base;
|
|
18443
|
+
let config;
|
|
18444
|
+
try {
|
|
18445
|
+
config = await fetchLoginConfig(apiBase);
|
|
18446
|
+
} catch (err) {
|
|
18447
|
+
return login_fail("LoginConfigError", err?.message || "Could not load login config.", "login-failed", "E_AUTH_FAILED");
|
|
18448
|
+
}
|
|
18449
|
+
if (!config.batch) return login_fail("LoginBatchUnsupported", BATCH_UNSUPPORTED_MESSAGE, "batch-unsupported", "E_PLATFORM", {
|
|
18450
|
+
value: {
|
|
18451
|
+
projects
|
|
18452
|
+
},
|
|
18453
|
+
hint: "Sign in to each project with its own extension_auth (action: login, project) call."
|
|
18454
|
+
});
|
|
18455
|
+
if (projects.length > config.batch.loginProjectsPerApproval) return login_fail("BadRequest", `projects names ${projects.length} projects, and one approval signs in to at most ${config.batch.loginProjectsPerApproval} on this platform.`, "bad-request", "E_BAD_REQUEST", {
|
|
18456
|
+
hint: `Split the list into calls of ${config.batch.loginProjectsPerApproval} or fewer; each call is its own approval. No device code was spent.`
|
|
18457
|
+
});
|
|
18458
|
+
let deviceCode = String(args.deviceCode || "").trim();
|
|
18459
|
+
let interval = 5;
|
|
18460
|
+
let budgetMs = login_RESUME_BUDGET_MS;
|
|
18461
|
+
let start = null;
|
|
18462
|
+
if (!deviceCode) {
|
|
18463
|
+
try {
|
|
18464
|
+
start = await requestDeviceCode({
|
|
18465
|
+
apiBase,
|
|
18466
|
+
path: config.deviceCodeUrl,
|
|
18467
|
+
projects
|
|
18468
|
+
});
|
|
18469
|
+
} catch (err) {
|
|
18470
|
+
return login_fail("LoginStartError", err?.message || "Could not start the device flow.", "login-failed", "E_AUTH_FAILED", {
|
|
18471
|
+
hint: "No device code was issued. Sign in to each project with its own extension_auth (action: login, project) call, or fix what the platform refused and try the list again."
|
|
18472
|
+
});
|
|
18473
|
+
}
|
|
18474
|
+
deviceCode = start.deviceCode;
|
|
18475
|
+
interval = start.interval;
|
|
18476
|
+
budgetMs = login_FIRST_CALL_BUDGET_MS;
|
|
18477
|
+
}
|
|
18478
|
+
const poll = await pollDeviceGrant({
|
|
18479
|
+
apiBase,
|
|
18480
|
+
path: config.deviceTokenUrl,
|
|
18481
|
+
projects,
|
|
18482
|
+
deviceCode,
|
|
18483
|
+
interval,
|
|
18484
|
+
budgetMs
|
|
18485
|
+
});
|
|
18486
|
+
if (!poll.ok) {
|
|
18487
|
+
if ("pending" === poll.reason) return batchPending({
|
|
18488
|
+
deviceCode,
|
|
18489
|
+
projects,
|
|
18490
|
+
verificationUri: start?.verificationUri ?? config.verificationUri,
|
|
18491
|
+
userCode: start?.userCode,
|
|
18492
|
+
verificationUriComplete: start?.verificationUriComplete
|
|
18493
|
+
});
|
|
18494
|
+
return batchRefusal(poll, projects);
|
|
18495
|
+
}
|
|
18496
|
+
let stored;
|
|
18497
|
+
try {
|
|
18498
|
+
stored = persistBatchTokenResponse({
|
|
18499
|
+
apiBase,
|
|
18500
|
+
projects,
|
|
18501
|
+
data: poll.data
|
|
18502
|
+
});
|
|
18503
|
+
} catch (err) {
|
|
18504
|
+
return login_fail("LoginError", err?.message ? String(err.message) : String(err), "login-failed", "E_AUTH_FAILED");
|
|
18505
|
+
}
|
|
18506
|
+
const logins = stored.map((creds)=>({
|
|
18507
|
+
project: `${creds.workspaceSlug}/${creds.projectSlug}`,
|
|
18508
|
+
workspaceSlug: creds.workspaceSlug,
|
|
18509
|
+
projectSlug: creds.projectSlug,
|
|
18510
|
+
expiresAt: creds.expiresAt ? new Date(1000 * creds.expiresAt).toISOString() : null
|
|
18511
|
+
}));
|
|
18512
|
+
return envelope_envelope({
|
|
18513
|
+
ok: true,
|
|
18514
|
+
command: "extension_auth",
|
|
18515
|
+
status: "logged-in",
|
|
18516
|
+
value: {
|
|
18517
|
+
workspaceSlug: parsed.batch.workspace,
|
|
18518
|
+
logins
|
|
18519
|
+
},
|
|
18520
|
+
hint: `Logged in to ${logins.length} project${1 === logins.length ? "" : "s"} in ${parsed.batch.workspace}: ${logins.map((login)=>login.projectSlug).join(", ")}. Each has its own stored token; token-scoped tools take \`project\` to pick one, and extension_auth (action: status) lists them. The default login was left as it was unless none existed. Every token expires within 7 days, so one batch login renews them together.`,
|
|
18521
|
+
warnings: [
|
|
18522
|
+
tokenTtlNote()
|
|
18523
|
+
]
|
|
18524
|
+
});
|
|
18525
|
+
}
|
|
17169
18526
|
const server_identity_DEFAULT_TIMEOUT_MS = 5000;
|
|
17170
18527
|
async function askServerIdentity(args) {
|
|
17171
18528
|
const fetchImpl = args.fetchImpl ?? fetch;
|
|
@@ -17355,7 +18712,7 @@ async function clearLocalCredentials(project) {
|
|
|
17355
18712
|
}
|
|
17356
18713
|
const auth_schema = {
|
|
17357
18714
|
name: "extension_auth",
|
|
17358
|
-
description: "Sign this machine in to extension.dev, report that login, or clear it. Pass action:'status' (the default) to name the workspace and project the stored token is scoped to and when it expires, never the token itself; that identity comes from the stored token alone, and does not change with the current working directory or whichever project folder you are in. Status also asks the platform's whoami endpoint whether that credential actually resolves there: the answer is reported as confirmed, refused-by-server, or unverified when the server cannot be reached, so a local file claiming a login the server would refuse is never reported as simply logged in. Pass action:'login' for a two-phase flow: call with `project` to get a code plus a URL the user authorizes at extension.dev/device, then call again with the returned `deviceCode`. GitHub federation happens server-side, so no GitHub token lands on this machine. Minted tokens live at most 7 days, server-enforced, so CI must re-mint before expiry on the console's project settings, Access tokens page. Pass action:'logout' to delete the local credentials only (with `project`, just that project's login); the token stays valid server-side until it is revoked at the URL the response returns. Several logins live side by side on one machine, one per workspace/project, and status lists them all under `logins`.",
|
|
18715
|
+
description: "Sign this machine in to extension.dev, report that login, or clear it. Pass action:'status' (the default) to name the workspace and project the stored token is scoped to and when it expires, never the token itself; that identity comes from the stored token alone, and does not change with the current working directory or whichever project folder you are in. Status also asks the platform's whoami endpoint whether that credential actually resolves there: the answer is reported as confirmed, refused-by-server, or unverified when the server cannot be reached, so a local file claiming a login the server would refuse is never reported as simply logged in. Pass action:'login' for a two-phase flow: call with `project` to get a code plus a URL the user authorizes at extension.dev/device, then call again with the returned `deviceCode`. GitHub federation happens server-side, so no GitHub token lands on this machine. Minted tokens live at most 7 days, server-enforced, so CI must re-mint before expiry on the console's project settings, Access tokens page. Pass action:'logout' to delete the local credentials only (with `project`, just that project's login); the token stays valid server-side until it is revoked at the URL the response returns. Several logins live side by side on one machine, one per workspace/project, and status lists them all under `logins`. To sign in to several existing projects of one workspace at once, pass `projects` instead of `project`: one approval, one stored token per project.",
|
|
17359
18716
|
inputSchema: {
|
|
17360
18717
|
type: "object",
|
|
17361
18718
|
properties: {
|
|
@@ -17372,6 +18729,13 @@ const auth_schema = {
|
|
|
17372
18729
|
type: "string",
|
|
17373
18730
|
description: "login: target project as '<workspace>/<project>'; the token is scoped to it. The slug pair is the console address bar: an existing project's page is console.extension.dev/<workspace>/<project>. Create one at extension.dev/new if none exists yet. Logins to several projects are all kept, the latest is the default; token-scoped tools take `project` to pick another. logout: remove only this project's login (omitted, every stored login goes)."
|
|
17374
18731
|
},
|
|
18732
|
+
projects: {
|
|
18733
|
+
type: "array",
|
|
18734
|
+
items: {
|
|
18735
|
+
type: "string"
|
|
18736
|
+
},
|
|
18737
|
+
description: "login: sign in to several existing projects of one workspace with one approval, instead of one approval each. 1 to 20 names as '<workspace>/<project>', all in the same workspace, each by its exact slug (lowercase letters and digits joined by single dashes, at most 48 characters), none twice. Pass it instead of `project`, never beside it. The approval page lists every name; one missing project refuses the whole list and mints nothing. Resume with the returned `deviceCode` and the same list. Every token is stored as that project's own login and all expire within 7 days, so the same call renews them together."
|
|
18738
|
+
},
|
|
17375
18739
|
deviceCode: {
|
|
17376
18740
|
type: "string",
|
|
17377
18741
|
description: "login: resume token from the prior call's `deviceCode`; omit on the first call."
|
|
@@ -17381,9 +18745,31 @@ const auth_schema = {
|
|
|
17381
18745
|
required: []
|
|
17382
18746
|
}
|
|
17383
18747
|
};
|
|
18748
|
+
function listMisuse(message) {
|
|
18749
|
+
return envelope_envelope({
|
|
18750
|
+
ok: false,
|
|
18751
|
+
command: "extension_auth",
|
|
18752
|
+
status: "bad-request",
|
|
18753
|
+
error: {
|
|
18754
|
+
code: "E_BAD_REQUEST",
|
|
18755
|
+
name: "BadRequest",
|
|
18756
|
+
message
|
|
18757
|
+
}
|
|
18758
|
+
});
|
|
18759
|
+
}
|
|
17384
18760
|
async function auth_handler(args) {
|
|
17385
18761
|
const action = args.action ?? "status";
|
|
18762
|
+
const hasList = void 0 !== args.projects && null !== args.projects;
|
|
18763
|
+
if (hasList && "login" !== action) return listMisuse(`projects is a login input: action '${action}' does not take a list. Use project to name one login, or call once per project.`);
|
|
17386
18764
|
if ("logout" === action) return clearLocalCredentials(args.project);
|
|
18765
|
+
if ("login" === action && hasList) {
|
|
18766
|
+
if (String(args.project ?? "").trim()) return listMisuse("Pass either project (one login) or projects (one approval for several), not both.");
|
|
18767
|
+
return loginToProjects({
|
|
18768
|
+
projects: args.projects,
|
|
18769
|
+
deviceCode: args.deviceCode,
|
|
18770
|
+
api: args.api
|
|
18771
|
+
});
|
|
18772
|
+
}
|
|
17387
18773
|
if ("login" === action) return loginToProject({
|
|
17388
18774
|
project: String(args.project ?? ""),
|
|
17389
18775
|
deviceCode: args.deviceCode,
|
|
@@ -18577,6 +19963,35 @@ function pinProjectArgs(name, args, inputSchema, options) {
|
|
|
18577
19963
|
if (!pinned) return {
|
|
18578
19964
|
args
|
|
18579
19965
|
};
|
|
19966
|
+
if (void 0 !== args.projects && null !== args.projects) {
|
|
19967
|
+
const entries = Array.isArray(args.projects) ? args.projects : [
|
|
19968
|
+
args.projects
|
|
19969
|
+
];
|
|
19970
|
+
const names = entries.map((entry)=>"string" == typeof entry ? entry.trim() : entry && "object" == typeof entry && !Array.isArray(entry) ? String(entry.project ?? "").trim() : "");
|
|
19971
|
+
const others = names.filter((entry)=>!entry.includes("/") || !sameProject(entry, pinned));
|
|
19972
|
+
if (others.length > 0 || 0 === entries.length) {
|
|
19973
|
+
const shown = others.map((entry)=>entry || "(an unnamed entry)");
|
|
19974
|
+
return {
|
|
19975
|
+
refused: envelope_envelope({
|
|
19976
|
+
ok: false,
|
|
19977
|
+
command: name,
|
|
19978
|
+
status: "project-pinned",
|
|
19979
|
+
error: {
|
|
19980
|
+
code: "E_TOOL_DISABLED",
|
|
19981
|
+
message: `This server is pinned to ${pinned} and was asked to act on a list naming ${shown.length ? shown.join(", ") : "no project"}.`
|
|
19982
|
+
},
|
|
19983
|
+
value: {
|
|
19984
|
+
pinned,
|
|
19985
|
+
named: shown
|
|
19986
|
+
},
|
|
19987
|
+
hint: `A pinned server takes a batch only when every entry is ${pinned}. Work on other projects through an MCP server configured without --project, or with its own --project (or ${PROJECT_PIN_ENV}).`
|
|
19988
|
+
})
|
|
19989
|
+
};
|
|
19990
|
+
}
|
|
19991
|
+
return {
|
|
19992
|
+
args
|
|
19993
|
+
};
|
|
19994
|
+
}
|
|
18580
19995
|
const named = "string" == typeof args.project ? args.project : "";
|
|
18581
19996
|
if (named && !sameProject(named, pinned)) return {
|
|
18582
19997
|
refused: envelope_envelope({
|