Skip to content

Commit 953c055

Browse files
priyanshu92Copilot
andcommitted
Merge PR 383 into safe ZIP validation
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2 parents fd3ae61 + 9294cd7 commit 953c055

5 files changed

Lines changed: 394 additions & 86 deletions

File tree

plugins/power-pages/.mcp.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"command": "node",
55
"args": [
66
"-e",
7-
"const fs=require('node:fs'); const path=require('node:path'); const root=process.env.PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT||process.cwd(); const entry=path.resolve(root,'scripts','launch-playwright-mcp.js'); if(!fs.existsSync(entry)) throw new Error('Could not resolve Power Pages plugin root; set PLUGIN_ROOT or launch from the plugin root'); const mod=require(entry); if(!mod||typeof mod.launch!=='function') throw new Error('Power Pages Playwright MCP launcher did not export launch()'); mod.launch();"
7+
"const fs=require('node:fs'); const path=require('node:path'); const fail=(message)=>{throw new Error('[Power Pages Playwright MCP] '+message);}; const declaredRoot=process.env.PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT; if(!declaredRoot) fail('PLUGIN_ROOT or CLAUDE_PLUGIN_ROOT must be set; refusing to resolve the launcher from the current working directory.'); if(!path.isAbsolute(declaredRoot)) fail('Declared plugin root must be an absolute path: '+declaredRoot+'.'); let root; try{root=fs.realpathSync(declaredRoot);}catch(error){fail('Declared plugin root is invalid: '+declaredRoot+' ('+(error.code||error.message)+').');} if(!fs.statSync(root).isDirectory()) fail('Declared plugin root is not a directory: '+declaredRoot+'.'); const candidate=path.resolve(root,'scripts','launch-playwright-mcp.js'); let entry; try{entry=fs.realpathSync(candidate);}catch(error){fail('Launcher was not found under the declared plugin root: '+candidate+'.');} const relative=path.relative(root,entry); if(relative.startsWith('..'+path.sep)||path.isAbsolute(relative)) fail('Resolved launcher escapes the declared plugin root: '+entry+'.'); if(!fs.statSync(entry).isFile()) fail('Resolved launcher is not a file: '+entry+'.'); const mod=require(entry); if(!mod||typeof mod.launch!=='function') fail('Launcher did not export launch(): '+entry+'.'); mod.launch();"
88
]
99
},
1010
"microsoft-learn": {

plugins/power-pages/README.md

Lines changed: 14 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,8 @@ The plugin ships with two MCP servers configured in `.mcp.json` — they start a
436436
| **playwright** | Headless browser automation for live previews and runtime tests |
437437
| **microsoft-learn** | Grounded search/fetch over official Microsoft Learn docs |
438438

439+
The plugin host must provide an absolute `PLUGIN_ROOT` (GitHub Copilot) or `CLAUDE_PLUGIN_ROOT` (Claude Code). The Playwright bootstrap resolves its launcher only from that declared plugin root and never from the workspace working directory.
440+
439441
## Typical Workflow
440442

441443
A common end-to-end workflow looks like this:
@@ -465,33 +467,20 @@ A common end-to-end workflow looks like this:
465467
466468
Steps can be run independently — you don't need to follow this exact order. Each skill checks its own prerequisites and will tell you if something is missing. If something goes wrong, `/diagnose-deployment` pattern-matches deployment errors and `/report-issue` opens a pre-filled GitHub issue.
467469

468-
## Running Without Interruption
469-
470-
The plugin invokes multiple tools during a session. To reduce approval prompts:
471-
472-
**Option 1 — Permission mode (recommended)**
473-
474-
```jsonc
475-
// .claude/settings.json
476-
{
477-
"defaultMode": "acceptEdits",
478-
"permissions": {
479-
"allow": [
480-
"Bash(npm run *)",
481-
"Bash(git *)",
482-
"Bash(pac *)",
483-
"Bash(az *)",
484-
"Bash(node *)"
485-
]
486-
}
487-
}
488-
```
470+
## Runtime approvals
489471

490-
**Option 2 — Auto-accept all**
472+
Keep your AI host's runtime approval prompts enabled while using this plugin.
473+
Plugin scripts run on your workstation with the filesystem access and cloud sign-in state available to your user account.
474+
A script that invokes `pac` or `az` may therefore act on Power Platform environments, Dataverse data, and Azure tenants that you can access.
491475

492-
```bash
493-
claude --dangerously-skip-permissions
494-
```
476+
Before approving a command, check the executable, script path, arguments, and target environment.
477+
Pay particular attention to commands that read or change project files, environment configuration, tenant resources, or business data.
478+
Do not grant blanket approval to command families such as `node`, `npm`, `git`, `pac`, or `az`.
479+
480+
If your host supports command-specific allow rules, use them only for an exact plugin script path that you have inspected and expect to run.
481+
Keep approval prompts for commands whose arguments or environment variables select a project, environment, tenant, or data source.
482+
Permission features and rule syntax vary by host and version, so follow the documentation for your host.
483+
Suppressing an approval prompt does not sandbox a script, restrict the programs it can start, or guarantee that the command is safe.
495484

496485
## ALM prompts you may see
497486

plugins/power-pages/scripts/launch-playwright-mcp.js

Lines changed: 68 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,50 +5,95 @@
55
// then falls back to Playwright's bundled Chromium.
66
// Self-contained — no external dependencies required.
77

8-
const { spawn } = require('child_process');
8+
const { spawn } = require('node:child_process');
9+
const fs = require('node:fs');
910
const path = require('path');
1011
const { detectBrowser } = require('./lib/detect-browser');
1112

12-
function quoteShellArg(value, platform = process.platform) {
13-
const argument = String(value);
14-
15-
if (platform === 'win32') {
16-
if (argument.includes('"')) {
17-
throw new Error('Cannot quote an argument containing double quotes for cmd.exe.');
18-
}
19-
20-
return `"${argument}"`;
21-
}
22-
23-
return `'${argument.replace(/'/g, "'\\''")}'`;
24-
}
13+
const PLAYWRIGHT_MCP_VERSION = '0.0.78';
14+
const PLAYWRIGHT_MCP_PACKAGE = `@playwright/mcp@${PLAYWRIGHT_MCP_VERSION}`;
2515

2616
function buildMcpArgs(browser, {
2717
configPath = path.join(__dirname, 'playwright-mcp-fullscreen.config.json'),
28-
platform = process.platform,
2918
} = {}) {
19+
// Marketplace installs copy only this plugin directory and do not run npm install,
20+
// so a lockfile would not materialize a local executable. Keep the runtime package
21+
// immutable, and disable lifecycle scripts while npx prepares the reviewed version.
3022
return [
31-
'-y',
32-
'@playwright/mcp@latest',
23+
'--yes',
24+
'--ignore-scripts',
25+
`--package=${PLAYWRIGHT_MCP_PACKAGE}`,
26+
'playwright-mcp',
3327
'--browser',
3428
browser,
3529
'--config',
36-
quoteShellArg(configPath, platform),
30+
configPath,
31+
];
32+
}
33+
34+
function resolveNpxCli({
35+
execPath = process.execPath,
36+
platform = process.platform,
37+
existsSync = fs.existsSync,
38+
} = {}) {
39+
// Windows exposes npx as a .cmd shim that cannot run with shell:false. Invoking
40+
// npm's JavaScript entrypoint through Node preserves raw argv on every platform.
41+
const pathApi = platform === 'win32' ? path.win32 : path.posix;
42+
const nodeDir = pathApi.dirname(execPath);
43+
const candidates = [
44+
pathApi.resolve(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npx-cli.js'),
45+
pathApi.join(nodeDir, 'node_modules', 'npm', 'bin', 'npx-cli.js'),
3746
];
47+
const match = candidates.find((candidate) => existsSync(candidate));
48+
49+
if (!match) {
50+
throw new Error(
51+
'Could not locate npm/bin/npx-cli.js beside the current Node installation. Install Node.js with npm before starting the Playwright MCP server.',
52+
);
53+
}
54+
55+
return match;
3856
}
3957

40-
function launch({ browser = detectBrowser(), spawnFn = spawn, onExit = (code) => process.exit(code || 0) } = {}) {
41-
const child = spawnFn('npx', buildMcpArgs(browser), {
58+
function launch({
59+
browser = detectBrowser(),
60+
npxCliPath,
61+
resolveNpxCliFn = resolveNpxCli,
62+
spawnFn = spawn,
63+
exitFn = (code) => process.exit(code),
64+
writeError = (message) => process.stderr.write(message),
65+
} = {}) {
66+
let resolvedNpxCliPath = npxCliPath;
67+
if (resolvedNpxCliPath === undefined) {
68+
try {
69+
resolvedNpxCliPath = resolveNpxCliFn();
70+
} catch (error) {
71+
writeError(`Failed to start Playwright MCP: ${error.message}\n`);
72+
exitFn(1);
73+
return null;
74+
}
75+
}
76+
77+
const child = spawnFn(process.execPath, [resolvedNpxCliPath, ...buildMcpArgs(browser)], {
4278
stdio: 'inherit',
43-
shell: true,
79+
shell: false,
4480
});
4581

46-
child.on('exit', onExit);
82+
child.once('error', (error) => {
83+
writeError(`Failed to start Playwright MCP: ${error.message}\n`);
84+
exitFn(1);
85+
});
86+
child.once('exit', (code) => exitFn(code ?? 1));
4787
return child;
4888
}
4989

5090
if (require.main === module) {
5191
launch();
5292
}
5393

54-
module.exports = { buildMcpArgs, launch, quoteShellArg };
94+
module.exports = {
95+
PLAYWRIGHT_MCP_PACKAGE,
96+
buildMcpArgs,
97+
launch,
98+
resolveNpxCli,
99+
};

plugins/power-pages/scripts/tests/launch-playwright-mcp.test.js

Lines changed: 145 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,69 @@ const test = require('node:test');
55
const { EventEmitter } = require('node:events');
66

77
const {
8+
PLAYWRIGHT_MCP_PACKAGE,
89
buildMcpArgs,
910
launch,
10-
quoteShellArg,
11+
resolveNpxCli,
1112
} = require('../launch-playwright-mcp');
1213

13-
test('buildMcpArgs launches Playwright MCP with fullscreen config', () => {
14+
test('buildMcpArgs launches the exact reviewed Playwright MCP version', () => {
1415
const expectedConfigPath = path.join(__dirname, '..', 'playwright-mcp-fullscreen.config.json');
1516
const args = buildMcpArgs('chrome');
1617
const configIndex = args.indexOf('--config');
1718

18-
assert.deepEqual(args.slice(0, 4), ['-y', '@playwright/mcp@latest', '--browser', 'chrome']);
19+
assert.equal(PLAYWRIGHT_MCP_PACKAGE, '@playwright/mcp@0.0.78');
20+
assert.deepEqual(
21+
args.slice(0, 6),
22+
[
23+
'--yes',
24+
'--ignore-scripts',
25+
'--package=@playwright/mcp@0.0.78',
26+
'playwright-mcp',
27+
'--browser',
28+
'chrome',
29+
],
30+
);
31+
assert.equal(args.some((arg) => /@(latest|next|\^|~|\*)$/.test(arg)), false);
1932
assert.equal(args.includes('--viewport-size'), false);
2033
assert.notEqual(configIndex, -1);
21-
assert.equal(args[configIndex + 1], quoteShellArg(expectedConfigPath));
34+
assert.equal(args[configIndex + 1], expectedConfigPath);
2235
});
2336

24-
test('buildMcpArgs quotes Windows config paths containing spaces', () => {
25-
const configPath = 'C:\\Users\\Power User\\.claude\\plugins\\power-pages\\scripts\\playwright-mcp-fullscreen.config.json';
26-
const args = buildMcpArgs('msedge', { configPath, platform: 'win32' });
37+
test('buildMcpArgs preserves config paths with spaces and shell metacharacters as raw argv', () => {
38+
const configPath = '/tmp/Power Pages $(echo unsafe); & [preview]/config\'s "quoted" path.json';
39+
const args = buildMcpArgs('chrome', { configPath });
2740
const configIndex = args.indexOf('--config');
2841

29-
assert.equal(args[configIndex + 1], `"${configPath}"`);
42+
assert.equal(args[configIndex + 1], configPath);
43+
});
44+
45+
test('buildMcpArgs preserves Windows config paths without shell quoting', () => {
46+
const configPath = 'C:\\Users\\Power User & Team\\Power Pages (Preview)\\playwright-mcp.config.json';
47+
const args = buildMcpArgs('msedge', { configPath });
48+
const configIndex = args.indexOf('--config');
49+
50+
assert.equal(args[configIndex + 1], configPath);
51+
});
52+
53+
test('resolveNpxCli finds the Windows npm JavaScript entrypoint', () => {
54+
const expected = 'C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npx-cli.js';
55+
const checked = [];
56+
57+
const resolved = resolveNpxCli({
58+
execPath: 'C:\\Program Files\\nodejs\\node.exe',
59+
platform: 'win32',
60+
existsSync(candidate) {
61+
checked.push(candidate);
62+
return candidate === expected;
63+
},
64+
});
65+
66+
assert.equal(resolved, expected);
67+
assert.deepEqual(checked, [
68+
'C:\\Program Files\\lib\\node_modules\\npm\\bin\\npx-cli.js',
69+
expected,
70+
]);
3071
});
3172

3273
test('fullscreen config maximizes the browser and uses the real viewport size', () => {
@@ -37,25 +78,116 @@ test('fullscreen config maximizes the browser and uses the real viewport size',
3778
assert.equal(config.browser.contextOptions.viewport, null);
3879
});
3980

40-
test('launch wires spawn and process exit handling', () => {
81+
test('launch preserves an explicit npx CLI path and uses raw argv without a shell', () => {
4182
let spawnCall;
4283
const child = new EventEmitter();
4384

4485
launch({
4586
browser: 'msedge',
87+
npxCliPath: 'C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npx-cli.js',
88+
resolveNpxCliFn() {
89+
assert.fail('explicit npxCliPath must bypass default resolution');
90+
},
4691
spawnFn(command, args, options) {
4792
spawnCall = { command, args, options };
4893
return child;
4994
},
50-
onExit(code) {
95+
exitFn(code) {
5196
spawnCall.exitCode = code;
5297
},
5398
});
5499

55-
assert.equal(spawnCall.command, 'npx');
56-
assert.deepEqual(spawnCall.args.slice(0, 4), ['-y', '@playwright/mcp@latest', '--browser', 'msedge']);
57-
assert.deepEqual(spawnCall.options, { stdio: 'inherit', shell: true });
100+
assert.equal(spawnCall.command, process.execPath);
101+
assert.deepEqual(
102+
spawnCall.args.slice(0, 7),
103+
[
104+
'C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npx-cli.js',
105+
'--yes',
106+
'--ignore-scripts',
107+
'--package=@playwright/mcp@0.0.78',
108+
'playwright-mcp',
109+
'--browser',
110+
'msedge',
111+
],
112+
);
113+
assert.deepEqual(spawnCall.options, { stdio: 'inherit', shell: false });
58114

59115
child.emit('exit', 7);
60116
assert.equal(spawnCall.exitCode, 7);
61117
});
118+
119+
test('launch reports missing npm once and does not spawn', () => {
120+
const exits = [];
121+
let spawnCalls = 0;
122+
let stderr = '';
123+
124+
const child = launch({
125+
browser: 'chrome',
126+
resolveNpxCliFn() {
127+
throw new Error('Could not locate npm/bin/npx-cli.js');
128+
},
129+
spawnFn() {
130+
spawnCalls += 1;
131+
return new EventEmitter();
132+
},
133+
exitFn(code) {
134+
exits.push(code);
135+
},
136+
writeError(message) {
137+
stderr += message;
138+
},
139+
});
140+
141+
assert.equal(child, null);
142+
assert.equal(spawnCalls, 0);
143+
assert.deepEqual(exits, [1]);
144+
assert.equal(
145+
stderr,
146+
'Failed to start Playwright MCP: Could not locate npm/bin/npx-cli.js\n',
147+
);
148+
});
149+
150+
test('launch reports spawn errors and exits with failure', () => {
151+
const child = new EventEmitter();
152+
const exits = [];
153+
let stderr = '';
154+
155+
launch({
156+
browser: 'chrome',
157+
npxCliPath: '/trusted/npm/bin/npx-cli.js',
158+
spawnFn() {
159+
return child;
160+
},
161+
exitFn(code) {
162+
exits.push(code);
163+
},
164+
writeError(message) {
165+
stderr += message;
166+
},
167+
});
168+
169+
child.emit('error', new Error('spawn ENOENT'));
170+
171+
assert.deepEqual(exits, [1]);
172+
assert.match(stderr, /^Failed to start Playwright MCP: spawn ENOENT\n$/);
173+
});
174+
175+
test('launch treats signal-only child exits as failures', () => {
176+
const child = new EventEmitter();
177+
let exitCode;
178+
179+
launch({
180+
browser: 'chrome',
181+
npxCliPath: '/trusted/npm/bin/npx-cli.js',
182+
spawnFn() {
183+
return child;
184+
},
185+
exitFn(code) {
186+
exitCode = code;
187+
},
188+
});
189+
190+
child.emit('exit', null, 'SIGTERM');
191+
192+
assert.equal(exitCode, 1);
193+
});

0 commit comments

Comments
 (0)