mcp-ado-browser 1.2.1
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/LICENSE +21 -0
- package/README.md +247 -0
- package/dist/src/ado/archive.js +69 -0
- package/dist/src/ado/client.js +478 -0
- package/dist/src/ado/hosts.js +36 -0
- package/dist/src/ado/schemas.js +178 -0
- package/dist/src/ado/versions.js +52 -0
- package/dist/src/browser/auth-detect.js +47 -0
- package/dist/src/browser/session.js +201 -0
- package/dist/src/cache/sqlite-cache.js +66 -0
- package/dist/src/cache/types.js +1 -0
- package/dist/src/cli.js +50 -0
- package/dist/src/config.js +70 -0
- package/dist/src/errors.js +88 -0
- package/dist/src/index.js +73 -0
- package/dist/src/logger.js +22 -0
- package/dist/src/mock/mock-ado-server.js +108 -0
- package/dist/src/runtime.js +155 -0
- package/dist/src/scrub.js +38 -0
- package/dist/src/server.js +51 -0
- package/dist/src/tools/defs.js +128 -0
- package/dist/src/tools/errors.js +7 -0
- package/dist/src/transport/mock-transport.js +73 -0
- package/dist/src/transport/types.js +8 -0
- package/package.json +70 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declared OUTPUT schemas (zod). Every tool validates its normalized output here
|
|
3
|
+
* before returning. A missing/mistyped field => ValidationError => a failing gate
|
|
4
|
+
* (schema-drift detection, mission §7).
|
|
5
|
+
*
|
|
6
|
+
* Outputs are NORMALIZED shapes (not raw ADO blobs): tools map raw ADO JSON into
|
|
7
|
+
* these, keeping the full raw payload under `raw`/`rawFields` for completeness.
|
|
8
|
+
*/
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { ValidationError } from "../errors.js";
|
|
11
|
+
export const RelationSchema = z.object({
|
|
12
|
+
rel: z.string(),
|
|
13
|
+
url: z.string(),
|
|
14
|
+
attributes: z.record(z.unknown()).optional(),
|
|
15
|
+
/** Parsed PR reference when rel is an ArtifactLink to a Git pull request. */
|
|
16
|
+
pullRequest: z
|
|
17
|
+
.object({ projectId: z.string(), repositoryId: z.string(), pullRequestId: z.number() })
|
|
18
|
+
.optional(),
|
|
19
|
+
/** Linked work item id when the relation points at another work item. */
|
|
20
|
+
workItemId: z.number().optional(),
|
|
21
|
+
});
|
|
22
|
+
export const WorkItemSchema = z.object({
|
|
23
|
+
id: z.number(),
|
|
24
|
+
rev: z.number(),
|
|
25
|
+
url: z.string(),
|
|
26
|
+
type: z.string(), // System.WorkItemType
|
|
27
|
+
title: z.string(), // System.Title
|
|
28
|
+
state: z.string(), // System.State
|
|
29
|
+
fields: z.record(z.unknown()),
|
|
30
|
+
relations: z.array(RelationSchema),
|
|
31
|
+
});
|
|
32
|
+
export const WorkItemSummarySchema = z.object({
|
|
33
|
+
id: z.number(),
|
|
34
|
+
type: z.string().nullable(),
|
|
35
|
+
title: z.string().nullable(),
|
|
36
|
+
state: z.string().nullable(),
|
|
37
|
+
fields: z.record(z.unknown()).optional(),
|
|
38
|
+
});
|
|
39
|
+
export const WorkItemSearchResultSchema = z.object({
|
|
40
|
+
count: z.number(),
|
|
41
|
+
items: z.array(WorkItemSummarySchema),
|
|
42
|
+
/** "wiql" | "almsearch" — which backend served the result. */
|
|
43
|
+
backend: z.string(),
|
|
44
|
+
});
|
|
45
|
+
export const CommentSchema = z.object({
|
|
46
|
+
id: z.number(),
|
|
47
|
+
text: z.string(),
|
|
48
|
+
createdBy: z.string().nullable(),
|
|
49
|
+
createdDate: z.string().nullable(),
|
|
50
|
+
modifiedDate: z.string().nullable().optional(),
|
|
51
|
+
});
|
|
52
|
+
export const WorkItemCommentsSchema = z.object({
|
|
53
|
+
workItemId: z.number(),
|
|
54
|
+
totalCount: z.number(),
|
|
55
|
+
count: z.number(),
|
|
56
|
+
comments: z.array(CommentSchema),
|
|
57
|
+
});
|
|
58
|
+
export const AttachmentRefSchema = z.object({
|
|
59
|
+
guid: z.string(),
|
|
60
|
+
name: z.string(),
|
|
61
|
+
url: z.string(),
|
|
62
|
+
/** Where the reference came from: a work-item relation or a comment body. */
|
|
63
|
+
source: z.enum(["relation", "comment-body"]),
|
|
64
|
+
});
|
|
65
|
+
export const DownloadedAttachmentSchema = AttachmentRefSchema.extend({
|
|
66
|
+
size: z.number(),
|
|
67
|
+
contentLength: z.number().nullable(),
|
|
68
|
+
sha256: z.string(),
|
|
69
|
+
savedPath: z.string().nullable(),
|
|
70
|
+
});
|
|
71
|
+
export const CommentDetailsSchema = z.object({
|
|
72
|
+
workItemId: z.number(),
|
|
73
|
+
comment: CommentSchema.nullable(),
|
|
74
|
+
attachments: z.array(DownloadedAttachmentSchema),
|
|
75
|
+
});
|
|
76
|
+
export const PullRequestSummarySchema = z.object({
|
|
77
|
+
pullRequestId: z.number(),
|
|
78
|
+
title: z.string().nullable(),
|
|
79
|
+
status: z.string().nullable(),
|
|
80
|
+
createdBy: z.string().nullable(),
|
|
81
|
+
sourceRefName: z.string().nullable(),
|
|
82
|
+
targetRefName: z.string().nullable(),
|
|
83
|
+
repositoryId: z.string().nullable(),
|
|
84
|
+
repositoryName: z.string().nullable(),
|
|
85
|
+
});
|
|
86
|
+
export const PullRequestSearchResultSchema = z.object({
|
|
87
|
+
count: z.number(),
|
|
88
|
+
items: z.array(PullRequestSummarySchema),
|
|
89
|
+
});
|
|
90
|
+
export const PullRequestSchema = z.object({
|
|
91
|
+
pullRequestId: z.number(),
|
|
92
|
+
title: z.string().nullable(),
|
|
93
|
+
description: z.string().nullable(),
|
|
94
|
+
status: z.string().nullable(),
|
|
95
|
+
createdBy: z.string().nullable(),
|
|
96
|
+
sourceRefName: z.string().nullable(),
|
|
97
|
+
targetRefName: z.string().nullable(),
|
|
98
|
+
repositoryId: z.string().nullable(),
|
|
99
|
+
repositoryName: z.string().nullable(),
|
|
100
|
+
reviewers: z.array(z.object({ id: z.string().nullable(), displayName: z.string().nullable(), vote: z.number().nullable() })),
|
|
101
|
+
workItemRefs: z.array(z.object({ id: z.string(), url: z.string() })),
|
|
102
|
+
raw: z.record(z.unknown()),
|
|
103
|
+
});
|
|
104
|
+
export const ThreadCommentSchema = z.object({
|
|
105
|
+
id: z.number(),
|
|
106
|
+
content: z.string().nullable(),
|
|
107
|
+
author: z.string().nullable(),
|
|
108
|
+
commentType: z.string().nullable(),
|
|
109
|
+
publishedDate: z.string().nullable(),
|
|
110
|
+
});
|
|
111
|
+
export const PrThreadSchema = z.object({
|
|
112
|
+
id: z.number(),
|
|
113
|
+
kind: z.enum(["system", "human"]),
|
|
114
|
+
status: z.string().nullable(),
|
|
115
|
+
comments: z.array(ThreadCommentSchema),
|
|
116
|
+
});
|
|
117
|
+
export const PullRequestCommentsSchema = z.object({
|
|
118
|
+
pullRequestId: z.number(),
|
|
119
|
+
threadCount: z.number(),
|
|
120
|
+
systemThreadCount: z.number(),
|
|
121
|
+
humanThreadCount: z.number(),
|
|
122
|
+
threads: z.array(PrThreadSchema),
|
|
123
|
+
});
|
|
124
|
+
export const PackageVersionSchema = z.object({ id: z.string().nullable(), version: z.string(), isLatest: z.boolean().nullable() });
|
|
125
|
+
export const PackageSchema = z.object({
|
|
126
|
+
id: z.string(),
|
|
127
|
+
name: z.string(),
|
|
128
|
+
protocolType: z.string().nullable(),
|
|
129
|
+
versions: z.array(PackageVersionSchema),
|
|
130
|
+
});
|
|
131
|
+
export const FeedSchema = z.object({ id: z.string(), name: z.string(), url: z.string().nullable() });
|
|
132
|
+
export const FeedsBrowseSchema = z.object({
|
|
133
|
+
feeds: z.array(FeedSchema),
|
|
134
|
+
/** Present when a specific feedId was browsed for its packages. */
|
|
135
|
+
packages: z.array(PackageSchema).optional(),
|
|
136
|
+
});
|
|
137
|
+
export const ProjectSchema = z.object({
|
|
138
|
+
id: z.string(),
|
|
139
|
+
name: z.string(),
|
|
140
|
+
state: z.string().nullable(),
|
|
141
|
+
description: z.string().nullable(),
|
|
142
|
+
lastUpdateTime: z.string().nullable(),
|
|
143
|
+
});
|
|
144
|
+
export const ProjectsListSchema = z.object({ count: z.number(), items: z.array(ProjectSchema) });
|
|
145
|
+
export const RepositorySchema = z.object({
|
|
146
|
+
id: z.string(),
|
|
147
|
+
name: z.string(),
|
|
148
|
+
project: z.string().nullable(),
|
|
149
|
+
defaultBranch: z.string().nullable(),
|
|
150
|
+
webUrl: z.string().nullable(),
|
|
151
|
+
isDisabled: z.boolean().nullable(),
|
|
152
|
+
});
|
|
153
|
+
export const RepositoriesListSchema = z.object({ count: z.number(), items: z.array(RepositorySchema) });
|
|
154
|
+
export const DownloadedArtifactSchema = z.object({
|
|
155
|
+
feedId: z.string(),
|
|
156
|
+
packageName: z.string(),
|
|
157
|
+
version: z.string(),
|
|
158
|
+
protocol: z.enum(["nuget", "npm"]),
|
|
159
|
+
size: z.number(),
|
|
160
|
+
contentLength: z.number().nullable(),
|
|
161
|
+
sha256: z.string(),
|
|
162
|
+
savedPath: z.string(),
|
|
163
|
+
archiveValid: z.boolean(),
|
|
164
|
+
archiveDetail: z.string(),
|
|
165
|
+
});
|
|
166
|
+
export const AuthResultSchema = z.object({
|
|
167
|
+
authenticated: z.boolean(),
|
|
168
|
+
identity: z.string().nullable(),
|
|
169
|
+
message: z.string(),
|
|
170
|
+
});
|
|
171
|
+
/** Validate `value` against `schema`, raising a ValidationError on drift. */
|
|
172
|
+
export function validateOutput(schema, value, label) {
|
|
173
|
+
const r = schema.safeParse(value);
|
|
174
|
+
if (!r.success) {
|
|
175
|
+
throw new ValidationError(`${label} output failed schema validation`, r.error.issues);
|
|
176
|
+
}
|
|
177
|
+
return r.data;
|
|
178
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* api-version handling. Mission rule: api-versions are NOT hardcoded across the
|
|
3
|
+
* codebase. They are either (a) supplied via ADO_API_VERSION, or (b) discovered,
|
|
4
|
+
* or (c) fall back to a SINGLE centralized default table living only here.
|
|
5
|
+
*
|
|
6
|
+
* `versions.ts` is the one and only place an api-version literal may appear, and
|
|
7
|
+
* the grep gate explicitly excludes this file from the "no hardcoded api-version"
|
|
8
|
+
* scan because here they are the documented, overridable fallback registry.
|
|
9
|
+
*/
|
|
10
|
+
/** Fallback defaults — used only when neither config override nor discovery applies. */
|
|
11
|
+
const DEFAULT_VERSIONS = {
|
|
12
|
+
wit: "7.1",
|
|
13
|
+
"wit-comments": "7.1-preview.4",
|
|
14
|
+
git: "7.1",
|
|
15
|
+
"packaging-feeds": "7.1-preview.1",
|
|
16
|
+
"packaging-pkgs": "7.1-preview.1",
|
|
17
|
+
search: "7.1-preview.1",
|
|
18
|
+
analytics: "v4.0-preview",
|
|
19
|
+
// connectionData is a PREVIEW resource: a non-preview api-version returns HTTP 400
|
|
20
|
+
// (VssInvalidPreviewVersionException). Confirmed empirically against a live org.
|
|
21
|
+
core: "7.1-preview",
|
|
22
|
+
};
|
|
23
|
+
export class VersionRegistry {
|
|
24
|
+
override;
|
|
25
|
+
discovered = {};
|
|
26
|
+
constructor(override) {
|
|
27
|
+
this.override = override;
|
|
28
|
+
}
|
|
29
|
+
/** Resolve effective version for an area: explicit override > discovered > default. */
|
|
30
|
+
forArea(area) {
|
|
31
|
+
if (this.override)
|
|
32
|
+
return this.override;
|
|
33
|
+
return this.discovered[area] ?? DEFAULT_VERSIONS[area];
|
|
34
|
+
}
|
|
35
|
+
/** Record a version learned via discovery (e.g. OPTIONS / ResourceAreas). */
|
|
36
|
+
setDiscovered(area, version) {
|
|
37
|
+
this.discovered[area] = version;
|
|
38
|
+
}
|
|
39
|
+
snapshot() {
|
|
40
|
+
const out = {};
|
|
41
|
+
for (const area of Object.keys(DEFAULT_VERSIONS))
|
|
42
|
+
out[area] = this.forArea(area);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Append api-version to a URL, respecting an already-present query string. */
|
|
47
|
+
export function withApiVersion(url, version) {
|
|
48
|
+
if (/[?&]api-version=/.test(url))
|
|
49
|
+
return url;
|
|
50
|
+
const sep = url.includes("?") ? "&" : "?";
|
|
51
|
+
return `${url}${sep}api-version=${encodeURIComponent(version)}`;
|
|
52
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login-detection logic, isolated from Playwright so it is deterministically
|
|
3
|
+
* testable against MockAdoServer (Gate 0). The interactive window is driven by
|
|
4
|
+
* BrowserSession; this module only decides "is this connectionData authenticated?"
|
|
5
|
+
* and runs the polling loop.
|
|
6
|
+
*/
|
|
7
|
+
import { AuthRequiredError } from "../errors.js";
|
|
8
|
+
const ANON_ID = "00000000-0000-0000-0000-000000000000";
|
|
9
|
+
/** Decide whether a connectionData payload represents a real, signed-in identity. */
|
|
10
|
+
export function detectIdentity(connectionData) {
|
|
11
|
+
const u = connectionData?.authenticatedUser;
|
|
12
|
+
if (!u)
|
|
13
|
+
return null;
|
|
14
|
+
const provider = u.providerDisplayName ?? u.properties?.Account?.$value;
|
|
15
|
+
const isAnon = provider === "Anonymous" || u.id === ANON_ID;
|
|
16
|
+
if (isAnon)
|
|
17
|
+
return null;
|
|
18
|
+
if (!u.subjectDescriptor && !u.descriptor)
|
|
19
|
+
return null;
|
|
20
|
+
return { id: String(u.id), displayName: String(provider ?? u.id), descriptor: u.subjectDescriptor ?? u.descriptor };
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Poll `fetchConnectionData` until it yields an authenticated identity or the
|
|
24
|
+
* deadline passes. `fetchConnectionData` should throw on 401 (dead session); we
|
|
25
|
+
* swallow that and keep waiting for the human to finish signing in.
|
|
26
|
+
*/
|
|
27
|
+
export async function pollUntilAuthenticated(fetchConnectionData, opts) {
|
|
28
|
+
const now = opts.now ?? (() => Date.now());
|
|
29
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
30
|
+
const interval = opts.intervalMs ?? 2000;
|
|
31
|
+
const deadline = now() + opts.timeoutMs;
|
|
32
|
+
do {
|
|
33
|
+
try {
|
|
34
|
+
const data = await fetchConnectionData();
|
|
35
|
+
const id = detectIdentity(data);
|
|
36
|
+
if (id)
|
|
37
|
+
return id;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
/* dead session / transient — keep polling until deadline */
|
|
41
|
+
}
|
|
42
|
+
if (now() >= deadline)
|
|
43
|
+
break;
|
|
44
|
+
await sleep(interval);
|
|
45
|
+
} while (now() < deadline);
|
|
46
|
+
throw new AuthRequiredError();
|
|
47
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BrowserSession — owns a Playwright persistent context on an ISOLATED, dedicated
|
|
3
|
+
* profile (never the user's daily browser). Work runs headless; the window is only
|
|
4
|
+
* made visible during the interactive (re)authentication flow.
|
|
5
|
+
*
|
|
6
|
+
* Data access is done via `page.evaluate(fetch(...))` executed in the page's own
|
|
7
|
+
* origin so the session cookies attach automatically and JSON comes back. The DOM
|
|
8
|
+
* is touched ONLY for the interactive login.
|
|
9
|
+
*
|
|
10
|
+
* Uses playwright-core + channel:'chrome'|'msedge' => relies on an already-installed
|
|
11
|
+
* browser and NEVER downloads a Playwright Chromium (mission §2 / restricted env).
|
|
12
|
+
*/
|
|
13
|
+
import { chromium } from "playwright-core";
|
|
14
|
+
import { AuthRequiredError, HttpError, NotFoundError } from "../errors.js";
|
|
15
|
+
import { log } from "../logger.js";
|
|
16
|
+
import { HostResolver } from "../ado/hosts.js";
|
|
17
|
+
import { VersionRegistry, withApiVersion } from "../ado/versions.js";
|
|
18
|
+
import { mandatoryHeaders } from "../transport/types.js";
|
|
19
|
+
import { detectIdentity, pollUntilAuthenticated } from "./auth-detect.js";
|
|
20
|
+
export class BrowserSession {
|
|
21
|
+
opts;
|
|
22
|
+
context;
|
|
23
|
+
page;
|
|
24
|
+
launchedHeadless;
|
|
25
|
+
hosts;
|
|
26
|
+
transport;
|
|
27
|
+
versions;
|
|
28
|
+
constructor(opts) {
|
|
29
|
+
this.opts = opts;
|
|
30
|
+
this.hosts = new HostResolver(opts.org);
|
|
31
|
+
this.transport = new BrowserTransport(this);
|
|
32
|
+
this.versions = opts.versions ?? new VersionRegistry(null);
|
|
33
|
+
}
|
|
34
|
+
connectionDataUrl() {
|
|
35
|
+
return withApiVersion(`${this.hosts.base("core")}/_apis/connectionData`, this.versions.forArea("core"));
|
|
36
|
+
}
|
|
37
|
+
async ensureLaunched(headless) {
|
|
38
|
+
if (this.context && this.launchedHeadless === headless)
|
|
39
|
+
return;
|
|
40
|
+
if (this.context)
|
|
41
|
+
await this.close();
|
|
42
|
+
log.info(`Launching ${this.opts.channel} (headless=${headless}) on isolated profile ${this.opts.userDataDir}`);
|
|
43
|
+
const args = ["--no-first-run", "--no-default-browser-check"];
|
|
44
|
+
// Visible (auth) window: open in Chrome "app" mode — a clean, chromeless window
|
|
45
|
+
// (no address bar, toolbar, tabs or bookmarks), pointed straight at the org URL.
|
|
46
|
+
// Opt out with ADO_APP_WINDOW=0.
|
|
47
|
+
if (!headless && process.env.ADO_APP_WINDOW !== "0") {
|
|
48
|
+
args.push(`--app=${this.hosts.base("core")}`, "--window-size=1100,820");
|
|
49
|
+
}
|
|
50
|
+
this.context = await chromium.launchPersistentContext(this.opts.userDataDir, {
|
|
51
|
+
headless,
|
|
52
|
+
channel: this.opts.channel,
|
|
53
|
+
viewport: null,
|
|
54
|
+
// Enable Chromium's sandbox for the VISIBLE auth window so it doesn't show the
|
|
55
|
+
// "--no-sandbox / security will suffer" banner (playwright-core defaults the
|
|
56
|
+
// sandbox OFF). Headless work runs keep the default (no banner is shown there
|
|
57
|
+
// anyway, and it avoids any sandbox-init issues in restricted environments).
|
|
58
|
+
// Force-disable everywhere with ADO_NO_SANDBOX=1.
|
|
59
|
+
chromiumSandbox: !headless && process.env.ADO_NO_SANDBOX !== "1",
|
|
60
|
+
args,
|
|
61
|
+
});
|
|
62
|
+
this.page = this.context.pages()[0] ?? (await this.context.newPage());
|
|
63
|
+
this.launchedHeadless = headless;
|
|
64
|
+
}
|
|
65
|
+
currentPage() {
|
|
66
|
+
if (!this.page)
|
|
67
|
+
throw new Error("BrowserSession not launched");
|
|
68
|
+
return this.page;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Interactive login. Opens a VISIBLE window, lets the human complete MFA, and
|
|
72
|
+
* detects success by polling an authenticated endpoint (connectionData) until it
|
|
73
|
+
* returns a real (non-anonymous) identity. Session is persisted in userDataDir.
|
|
74
|
+
*/
|
|
75
|
+
async authenticate(timeoutMs = 300_000) {
|
|
76
|
+
await this.ensureLaunched(false);
|
|
77
|
+
const page = this.currentPage();
|
|
78
|
+
// Navigate to the ORG-scoped URL (not the bare origin): the bare dev.azure.com
|
|
79
|
+
// root redirects unauthenticated users to the marketing page, while the
|
|
80
|
+
// org-scoped URL triggers the AAD sign-in flow.
|
|
81
|
+
const orgUrl = this.hosts.base("core");
|
|
82
|
+
try {
|
|
83
|
+
await page.goto(orgUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
log.debug(`authenticate initial nav note: ${String(e)}`);
|
|
87
|
+
}
|
|
88
|
+
log.info("Waiting for interactive sign-in (MFA). Complete the login in the open window.");
|
|
89
|
+
// Poll via context.request so we read connectionData using the SHARED session
|
|
90
|
+
// cookie jar without CORS and WITHOUT navigating the user's login tab away.
|
|
91
|
+
const id = await pollUntilAuthenticated(() => this.requestConnectionData(), { timeoutMs });
|
|
92
|
+
log.info(`Signed in as ${id.displayName}`);
|
|
93
|
+
return id;
|
|
94
|
+
}
|
|
95
|
+
/** Read connectionData via the context's cookie jar (used by auth polling + validate). */
|
|
96
|
+
async requestConnectionData() {
|
|
97
|
+
if (!this.context)
|
|
98
|
+
throw new Error("BrowserSession not launched");
|
|
99
|
+
const res = await this.context.request.get(this.connectionDataUrl(), { headers: mandatoryHeaders() });
|
|
100
|
+
if (res.status() === 401)
|
|
101
|
+
throw new AuthRequiredError(this.connectionDataUrl());
|
|
102
|
+
if (!res.ok())
|
|
103
|
+
throw new HttpError(res.status(), this.connectionDataUrl());
|
|
104
|
+
return res.json();
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Cross-host data fetch via the context cookie jar. Used for hosts other than the
|
|
108
|
+
* core dev.azure.com origin (feeds/pkgs/search/analytics): navigating a real page
|
|
109
|
+
* to those hosts is unreliable (bare org paths 4xx, download URLs trigger file
|
|
110
|
+
* downloads), but context.request carries the SAME authenticated session cookies
|
|
111
|
+
* without CORS or page navigation. Still strictly the browser session — no PAT.
|
|
112
|
+
*/
|
|
113
|
+
async contextFetchJson(url, init) {
|
|
114
|
+
const res = await this.context.request.fetch(url, { method: init?.method ?? "GET", headers: mandatoryHeaders(init?.headers), data: init?.body });
|
|
115
|
+
const headers = res.headers();
|
|
116
|
+
if (res.status() === 401 || res.status() === 403)
|
|
117
|
+
throw new AuthRequiredError(url);
|
|
118
|
+
if (res.status() === 404)
|
|
119
|
+
throw new NotFoundError("resource", url, url);
|
|
120
|
+
if (!res.ok())
|
|
121
|
+
throw new HttpError(res.status(), url, (await res.text()).slice(0, 500));
|
|
122
|
+
return { data: (await res.json()), headers };
|
|
123
|
+
}
|
|
124
|
+
async contextFetchBuffer(url, init) {
|
|
125
|
+
const res = await this.context.request.fetch(url, { method: init?.method ?? "GET", headers: mandatoryHeaders({ Accept: "application/octet-stream", ...(init?.headers ?? {}) }), data: init?.body });
|
|
126
|
+
const headers = res.headers();
|
|
127
|
+
if (res.status() === 401 || res.status() === 403)
|
|
128
|
+
throw new AuthRequiredError(url);
|
|
129
|
+
if (res.status() === 404)
|
|
130
|
+
throw new NotFoundError("resource", url, url);
|
|
131
|
+
if (!res.ok())
|
|
132
|
+
throw new HttpError(res.status(), url, (await res.text()).slice(0, 500));
|
|
133
|
+
const data = await res.body();
|
|
134
|
+
const cl = headers["content-length"];
|
|
135
|
+
return { data, contentLength: cl != null ? Number(cl) : null, contentType: headers["content-type"] ?? null, headers };
|
|
136
|
+
}
|
|
137
|
+
/** Who is the persisted session signed in as? Returns null if not authenticated. */
|
|
138
|
+
async whoami() {
|
|
139
|
+
try {
|
|
140
|
+
await this.ensureLaunched(true);
|
|
141
|
+
return detectIdentity(await this.requestConnectionData());
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Lightweight check: is the persisted session currently valid? */
|
|
148
|
+
async validate() {
|
|
149
|
+
return (await this.whoami()) !== null;
|
|
150
|
+
}
|
|
151
|
+
async close() {
|
|
152
|
+
try {
|
|
153
|
+
await this.context?.close();
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
/* ignore */
|
|
157
|
+
}
|
|
158
|
+
this.context = undefined;
|
|
159
|
+
this.page = undefined;
|
|
160
|
+
this.launchedHeadless = undefined;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* AdoTransport over a live BrowserSession. Every request goes through the browser
|
|
165
|
+
* CONTEXT's cookie jar (Playwright APIRequestContext): it carries the SAME
|
|
166
|
+
* authenticated session cookies as the page, works same- AND cross-host, and returns
|
|
167
|
+
* clean HTTP status codes (401 -> AUTH_REQUIRED, 404 -> NOT_FOUND).
|
|
168
|
+
*
|
|
169
|
+
* Why not page.evaluate(fetch)? It proved unreliable in real headless runtimes:
|
|
170
|
+
* navigating to a JSON endpoint, SPA redirects, and corporate proxies (Chrome's
|
|
171
|
+
* renderer network stack vs Node's) surface as "TypeError: Failed to fetch". The
|
|
172
|
+
* cookie jar uses the same session but Node's network path — robust everywhere.
|
|
173
|
+
*/
|
|
174
|
+
export class BrowserTransport {
|
|
175
|
+
session;
|
|
176
|
+
kind = "browser";
|
|
177
|
+
calledUrls = [];
|
|
178
|
+
fetchCount = 0;
|
|
179
|
+
lastHeaders = {};
|
|
180
|
+
constructor(session) {
|
|
181
|
+
this.session = session;
|
|
182
|
+
}
|
|
183
|
+
resetCounters() {
|
|
184
|
+
this.calledUrls.length = 0;
|
|
185
|
+
this.fetchCount = 0;
|
|
186
|
+
}
|
|
187
|
+
async fetchJson(url, init) {
|
|
188
|
+
this.calledUrls.push(url);
|
|
189
|
+
this.fetchCount++;
|
|
190
|
+
const res = await this.session.contextFetchJson(url, init);
|
|
191
|
+
this.lastHeaders = res.headers;
|
|
192
|
+
return res;
|
|
193
|
+
}
|
|
194
|
+
async fetchBuffer(url, init) {
|
|
195
|
+
this.calledUrls.push(url);
|
|
196
|
+
this.fetchCount++;
|
|
197
|
+
const res = await this.session.contextFetchBuffer(url, init);
|
|
198
|
+
this.lastHeaders = res.headers;
|
|
199
|
+
return res;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SqliteCache — persistent query cache backed by node:sqlite (built into Node >=22.5).
|
|
3
|
+
*
|
|
4
|
+
* Why node:sqlite (mission §2): zero native build, nothing to compile, nothing that
|
|
5
|
+
* a restricted environment can block at install time. better-sqlite3 was rejected
|
|
6
|
+
* for its native build step. The DB file persists across process restarts (Gate 2).
|
|
7
|
+
*
|
|
8
|
+
* Logical cache identity is (kind, key, version): the row carries the resource
|
|
9
|
+
* version (work item Rev, etc.) so a freshness oracle can confirm the cached value
|
|
10
|
+
* is still current without a full re-fetch.
|
|
11
|
+
*/
|
|
12
|
+
import { DatabaseSync } from "node:sqlite";
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
export class SqliteCache {
|
|
16
|
+
opts;
|
|
17
|
+
db;
|
|
18
|
+
constructor(opts) {
|
|
19
|
+
this.opts = opts;
|
|
20
|
+
if (opts.dbPath !== ":memory:")
|
|
21
|
+
fs.mkdirSync(path.dirname(opts.dbPath), { recursive: true });
|
|
22
|
+
this.db = new DatabaseSync(opts.dbPath);
|
|
23
|
+
this.db.exec(`
|
|
24
|
+
CREATE TABLE IF NOT EXISTS cache (
|
|
25
|
+
kind TEXT NOT NULL,
|
|
26
|
+
key TEXT NOT NULL,
|
|
27
|
+
version TEXT NOT NULL,
|
|
28
|
+
value TEXT NOT NULL,
|
|
29
|
+
stored_at INTEGER NOT NULL,
|
|
30
|
+
validated_at INTEGER NOT NULL,
|
|
31
|
+
PRIMARY KEY (kind, key)
|
|
32
|
+
);
|
|
33
|
+
`);
|
|
34
|
+
}
|
|
35
|
+
get(kind, key) {
|
|
36
|
+
const row = this.db.prepare(`SELECT version, value, stored_at, validated_at FROM cache WHERE kind = ? AND key = ?`).get(kind, key);
|
|
37
|
+
if (!row)
|
|
38
|
+
return null;
|
|
39
|
+
return { value: JSON.parse(row.value), version: row.version, storedAt: row.stored_at, validatedAt: row.validated_at };
|
|
40
|
+
}
|
|
41
|
+
set(kind, key, value, version) {
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
this.db
|
|
44
|
+
.prepare(`INSERT INTO cache (kind, key, version, value, stored_at, validated_at)
|
|
45
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
46
|
+
ON CONFLICT(kind, key) DO UPDATE SET version=excluded.version, value=excluded.value, stored_at=excluded.stored_at, validated_at=excluded.validated_at`)
|
|
47
|
+
.run(kind, key, version, JSON.stringify(value), now, now);
|
|
48
|
+
}
|
|
49
|
+
touch(kind, key) {
|
|
50
|
+
this.db.prepare(`UPDATE cache SET validated_at = ? WHERE kind = ? AND key = ?`).run(Date.now(), kind, key);
|
|
51
|
+
}
|
|
52
|
+
ttlFor(kind) {
|
|
53
|
+
const o = this.opts.ttlOverrides[kind];
|
|
54
|
+
return o !== undefined ? o : this.opts.defaultTtlSeconds;
|
|
55
|
+
}
|
|
56
|
+
clear() {
|
|
57
|
+
this.db.exec(`DELETE FROM cache`);
|
|
58
|
+
}
|
|
59
|
+
/** Test/diagnostic hook: artificially age an entry so the freshness path triggers. */
|
|
60
|
+
expire(kind, key) {
|
|
61
|
+
this.db.prepare(`UPDATE cache SET validated_at = 0, stored_at = 0 WHERE kind = ? AND key = ?`).run(kind, key);
|
|
62
|
+
}
|
|
63
|
+
close() {
|
|
64
|
+
this.db.close();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const FLAG_TO_ENV = {
|
|
2
|
+
org: "ADO_ORG",
|
|
3
|
+
project: "ADO_PROJECT",
|
|
4
|
+
channel: "ADO_BROWSER_CHANNEL",
|
|
5
|
+
"user-data-dir": "ADO_USER_DATA_DIR",
|
|
6
|
+
"cache-ttl": "ADO_CACHE_TTL_SECONDS",
|
|
7
|
+
"api-version": "ADO_API_VERSION",
|
|
8
|
+
"log-level": "ADO_LOG_LEVEL",
|
|
9
|
+
};
|
|
10
|
+
/** Boolean flags (presence => value). */
|
|
11
|
+
const BOOL_FLAGS = {
|
|
12
|
+
"no-app-window": ["ADO_APP_WINDOW", "0"],
|
|
13
|
+
"no-sandbox": ["ADO_NO_SANDBOX", "1"],
|
|
14
|
+
headed: ["ADO_HEADLESS", "0"],
|
|
15
|
+
};
|
|
16
|
+
export function parseArgs(argv) {
|
|
17
|
+
const env = {};
|
|
18
|
+
let command = null;
|
|
19
|
+
for (let i = 0; i < argv.length; i++) {
|
|
20
|
+
const tok = argv[i];
|
|
21
|
+
if (tok.startsWith("--")) {
|
|
22
|
+
const body = tok.slice(2);
|
|
23
|
+
const eq = body.indexOf("=");
|
|
24
|
+
const name = eq === -1 ? body : body.slice(0, eq);
|
|
25
|
+
if (BOOL_FLAGS[name]) {
|
|
26
|
+
const [k, v] = BOOL_FLAGS[name];
|
|
27
|
+
env[k] = v;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const envKey = FLAG_TO_ENV[name];
|
|
31
|
+
if (!envKey)
|
|
32
|
+
continue; // unknown flag: ignore (help/-h handled by caller)
|
|
33
|
+
let value;
|
|
34
|
+
if (eq !== -1)
|
|
35
|
+
value = body.slice(eq + 1);
|
|
36
|
+
else
|
|
37
|
+
value = argv[++i] ?? "";
|
|
38
|
+
env[envKey] = value;
|
|
39
|
+
}
|
|
40
|
+
else if (command === null) {
|
|
41
|
+
command = tok;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { command, env };
|
|
45
|
+
}
|
|
46
|
+
/** Apply parsed flag overrides onto process.env (CLI precedence over existing env). */
|
|
47
|
+
export function applyArgs(parsed) {
|
|
48
|
+
for (const [k, v] of Object.entries(parsed.env))
|
|
49
|
+
process.env[k] = v;
|
|
50
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* All runtime configuration is sourced from environment variables (mission §9).
|
|
3
|
+
* Nothing about a specific org/project/id/api-version is ever hardcoded — every
|
|
4
|
+
* such value flows through here or through dynamic discovery.
|
|
5
|
+
*/
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import * as os from "node:os";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { ConfigError } from "./errors.js";
|
|
10
|
+
const intFromEnv = (def) => z
|
|
11
|
+
.string()
|
|
12
|
+
.optional()
|
|
13
|
+
.transform((v) => (v === undefined || v === "" ? def : Number(v)))
|
|
14
|
+
.pipe(z.number().int().nonnegative());
|
|
15
|
+
/** Default persistent profile dir: an isolated, dedicated profile (NOT the user's daily browser). */
|
|
16
|
+
function defaultUserDataDir() {
|
|
17
|
+
return path.join(os.homedir(), ".mcp-ado-browser", "profile");
|
|
18
|
+
}
|
|
19
|
+
const ChannelSchema = z.enum(["chrome", "msedge"]);
|
|
20
|
+
/**
|
|
21
|
+
* Parse env. `requireConnection` enforces org/project presence (needed for live);
|
|
22
|
+
* mock-only runs can proceed without them.
|
|
23
|
+
*/
|
|
24
|
+
export function loadConfig(env = process.env) {
|
|
25
|
+
const channel = ChannelSchema.safeParse(env.ADO_BROWSER_CHANNEL ?? "chrome");
|
|
26
|
+
if (!channel.success)
|
|
27
|
+
throw new ConfigError(`ADO_BROWSER_CHANNEL must be 'chrome' or 'msedge'`);
|
|
28
|
+
const userDataDir = env.ADO_USER_DATA_DIR && env.ADO_USER_DATA_DIR.trim() !== "" ? env.ADO_USER_DATA_DIR : defaultUserDataDir();
|
|
29
|
+
const ttl = intFromEnv(900).parse(env.ADO_CACHE_TTL_SECONDS);
|
|
30
|
+
// Per-resource overrides: ADO_CACHE_TTL_<RESOURCE>=seconds (e.g. ADO_CACHE_TTL_WORKITEM=60)
|
|
31
|
+
const overrides = {};
|
|
32
|
+
for (const [k, v] of Object.entries(env)) {
|
|
33
|
+
const m = k.match(/^ADO_CACHE_TTL_([A-Z_]+)$/);
|
|
34
|
+
if (m && v != null && v !== "" && k !== "ADO_CACHE_TTL_SECONDS") {
|
|
35
|
+
const n = Number(v);
|
|
36
|
+
if (Number.isFinite(n) && n >= 0)
|
|
37
|
+
overrides[m[1].toLowerCase()] = n;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const dataRoot = path.join(userDataDir, "..");
|
|
41
|
+
const num = (v) => (v && v.trim() !== "" ? Number(v) : null);
|
|
42
|
+
const str = (v) => (v && v.trim() !== "" ? v.trim() : null);
|
|
43
|
+
return {
|
|
44
|
+
org: str(env.ADO_ORG),
|
|
45
|
+
project: str(env.ADO_PROJECT),
|
|
46
|
+
userDataDir,
|
|
47
|
+
browserChannel: channel.data,
|
|
48
|
+
cacheTtlSeconds: ttl,
|
|
49
|
+
cacheTtlOverrides: overrides,
|
|
50
|
+
apiVersionOverride: str(env.ADO_API_VERSION),
|
|
51
|
+
cacheDbPath: env.ADO_CACHE_DB && env.ADO_CACHE_DB.trim() !== "" ? env.ADO_CACHE_DB : path.join(dataRoot, "cache.sqlite"),
|
|
52
|
+
fixturesDir: env.ADO_FIXTURES_DIR && env.ADO_FIXTURES_DIR.trim() !== "" ? env.ADO_FIXTURES_DIR : path.join(process.cwd(), "fixtures"),
|
|
53
|
+
test: {
|
|
54
|
+
workItemId: num(env.ADO_TEST_WORKITEM_ID),
|
|
55
|
+
repoId: str(env.ADO_TEST_REPO_ID),
|
|
56
|
+
prId: num(env.ADO_TEST_PR),
|
|
57
|
+
feedId: str(env.ADO_TEST_FEED),
|
|
58
|
+
},
|
|
59
|
+
headless: env.ADO_HEADLESS === "0" ? false : true,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Only the ORGANIZATION is required. The project is optional: the server browses
|
|
64
|
+
* EVERY project, repo and feed the user can access. A configured project is only
|
|
65
|
+
* ever used as a default scope for tools that accept an optional `project` arg.
|
|
66
|
+
*/
|
|
67
|
+
export function requireConnection(cfg) {
|
|
68
|
+
if (!cfg.org)
|
|
69
|
+
throw new ConfigError("ADO_ORG is required");
|
|
70
|
+
}
|