Repository navigation
feat(web-shell): share HTML artifacts through managed hosting #10024
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
f1a6fc4
03f9870
fa917a2
34173f1
3063685
b16bdcd
bba81a3
73381f8
1f6c187
e34a6cf
a0035e3
09747eb
4ab71b5
f4eab1b
74e1489
7330ba5
b2f4c82
de76d60
7d2f844
8f3ccb8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
- Loading branch information
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |
|
|
||
| import * as path from 'node:path'; | ||
| import * as http from 'node:http'; | ||
| import * as fsp from 'node:fs/promises'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
| import express from 'express'; | ||
|
|
@@ -508,6 +509,14 @@ describe('GET /workspace/artifact/publish-config', () => { | |
| env: { | ||
| PATH: '/workspace/bin', | ||
| NODE_OPTIONS: '--import /workspace/evil.js', | ||
| NETLIFY_AUTH_TOKEN: 'attacker-token', | ||
| NETLIFY_API_URL: 'https://attacker.example', | ||
| NETLIFY_SITE_ID: 'attacker-site', | ||
| CLOUDFLARE_API_TOKEN: 'attacker-token', | ||
| CLOUDFLARE_API_BASE_URL: 'https://attacker.example', | ||
| CF_API_BASE_URL: 'https://attacker.example', | ||
| VERCEL_TOKEN: 'attacker-token', | ||
| XDG_DATA_HOME: '/workspace/.xdg', | ||
| }, | ||
| }); | ||
| mockSettings({ | ||
|
|
@@ -520,11 +529,9 @@ describe('GET /workspace/artifact/publish-config', () => { | |
| }, | ||
| }); | ||
| const runCommand: ArtifactRouteCommandRunner = vi.fn( | ||
| async (command, args, options) => { | ||
| async (command, args) => { | ||
| expect(command).toBe(process.execPath); | ||
| expect(args[0]).toBe(TEST_NETLIFY_ENTRY); | ||
| expect(options.env['PATH']).toBe(process.env['PATH']); | ||
| expect(options.env['NODE_OPTIONS']).toBeUndefined(); | ||
| if (args[1] === '--version') return '27.1.2'; | ||
| if (args[1] === 'api') throw new Error('not authenticated'); | ||
| throw new Error(`Unexpected command: ${args.join(' ')}`); | ||
|
|
@@ -548,6 +555,22 @@ describe('GET /workspace/artifact/publish-config', () => { | |
| .mocked(runCommand) | ||
| .mock.calls.every(([command]) => command === process.execPath), | ||
| ).toBe(true); | ||
| const netlifyCalls = vi | ||
| .mocked(runCommand) | ||
| .mock.calls.filter(([, args]) => args[0] === TEST_NETLIFY_ENTRY); | ||
| expect(netlifyCalls.length).toBeGreaterThan(0); | ||
| for (const [, , options] of netlifyCalls) { | ||
| expect(options.env['PATH']).toBe(process.env['PATH']); | ||
| expect(options.env['NODE_OPTIONS']).toBeUndefined(); | ||
| expect(options.env['NETLIFY_AUTH_TOKEN']).toBeUndefined(); | ||
| expect(options.env['NETLIFY_API_URL']).toBeUndefined(); | ||
| expect(options.env['NETLIFY_SITE_ID']).toBeUndefined(); | ||
| expect(options.env['CLOUDFLARE_API_TOKEN']).toBeUndefined(); | ||
| expect(options.env['CLOUDFLARE_API_BASE_URL']).toBeUndefined(); | ||
| expect(options.env['CF_API_BASE_URL']).toBeUndefined(); | ||
| expect(options.env['VERCEL_TOKEN']).toBeUndefined(); | ||
| expect(options.env['XDG_DATA_HOME']).toBe(process.env['XDG_DATA_HOME']); | ||
| } | ||
| }); | ||
|
|
||
| it('runs the JavaScript CLI entrypoint on Windows instead of .cmd shims', async () => { | ||
|
|
@@ -815,7 +838,10 @@ describe('POST /workspace/artifact/netlify/setup', () => { | |
| .post('/workspace/artifact/netlify/setup') | ||
| .send({ action: 'poll' }); | ||
|
Comment on lines
+939
to
+941
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deferred under the workflow's budget warning — this round was a same-run verification repair limited to the deterministic test rejection ( 中文说明因工作流预算警告延后——本轮是针对确定性测试拒绝的同轮验证修复(
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deferred under the workflow's budget warning — this round was a same-run verification repair limited to the deterministic test rejection ( 中文说明因工作流预算警告延后——本轮是针对确定性测试拒绝的同轮验证修复(
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deferred under the workflow's budget warning — this round was a same-run verification repair limited to the deterministic test rejection ( 中文说明因工作流预算警告延后——本轮是针对确定性测试拒绝的同轮验证修复(
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deferred under the workflow's budget warning — this round was a same-run verification repair limited to the deterministic test rejection ( 中文说明因工作流预算警告延后——本轮是针对确定性测试拒绝的同轮验证修复( |
||
|
|
||
| expect(response.status).toBe(200); | ||
| expect( | ||
| response.status, | ||
| `setup returned ${JSON.stringify(response.body)}`, | ||
|
Comment on lines
+943
to
+945
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deferred: Suggestion-level witness gap (pollLogin denied/pending/unexpected branches untested), still open. This round was scoped to the rejected verification gate under the budget warning, so non-essential work was not retried. 中文说明延后:建议级见证缺口(pollLogin 的 denied/pending/意外状态分支无测试),仍开放。本轮在预算警告下只处理被拒的验证门禁,未重试非必要工作。 |
||
| ).toBe(200); | ||
| expect(response.body.setup).toMatchObject({ | ||
| stage: 'ready', | ||
| authenticated: true, | ||
|
|
@@ -888,7 +914,10 @@ describe('POST /workspace/artifact/netlify/setup', () => { | |
| .post('/workspace/artifact/netlify/setup') | ||
| .send({ action: 'prepare' }); | ||
|
|
||
| expect(response.status).toBe(200); | ||
| expect( | ||
| response.status, | ||
| `setup returned ${JSON.stringify(response.body)}`, | ||
| ).toBe(200); | ||
| expect(response.body.setup).toMatchObject({ | ||
| stage: 'ready', | ||
| authenticated: true, | ||
|
|
@@ -974,7 +1003,10 @@ describe('POST /workspace/artifact/netlify/setup', () => { | |
| .post('/workspace/artifact/netlify/setup') | ||
| .send({ action: 'connect' }); | ||
|
|
||
| expect(response.status).toBe(200); | ||
| expect( | ||
| response.status, | ||
| `setup returned ${JSON.stringify(response.body)}`, | ||
| ).toBe(200); | ||
| expect(response.body.setup).toMatchObject({ | ||
| stage: 'ready', | ||
| linked: true, | ||
|
|
@@ -1174,7 +1206,10 @@ describe('POST /workspace/artifact/netlify/setup', () => { | |
| .post('/workspace/artifact/netlify/setup') | ||
| .send({ action: 'connect' }); | ||
|
|
||
| expect(response.status).toBe(200); | ||
| expect( | ||
| response.status, | ||
| `setup returned ${JSON.stringify(response.body)}`, | ||
| ).toBe(200); | ||
| expect(response.body.setup).toMatchObject({ | ||
| stage: 'ready', | ||
| configured: true, | ||
|
|
@@ -1227,6 +1262,85 @@ describe('POST /workspace/artifact/netlify/setup', () => { | |
| .mock.calls.some(([, args]) => args[0] === 'sites:create'), | ||
| ).toBe(false); | ||
| }); | ||
|
|
||
| // Self-referential symlink creation is not reliably permitted on Windows. | ||
| it.skipIf(process.platform === 'win32')( | ||
| 'creates a dedicated project when the boundary probe cannot read .git', | ||
| async () => { | ||
| const workspaceCwd = await fsp.mkdtemp( | ||
| path.join(tmpdir(), 'qwen-art-boundary-'), | ||
| ); | ||
| const settingsByWorkspace: Record<string, Record<string, unknown>> = { | ||
| [workspaceCwd]: {}, | ||
| }; | ||
| mockSettings(settingsByWorkspace); | ||
| await fsp.symlink( | ||
| path.join(workspaceCwd, '.git'), | ||
| path.join(workspaceCwd, '.git'), | ||
| ); | ||
| try { | ||
| const primary = runtime('primary', workspaceCwd, { primary: true }); | ||
| const runCommand: ArtifactRouteCommandRunner = vi.fn( | ||
| async (_command, args) => { | ||
| if (args[0] === '--version') return '27.1.2'; | ||
| if (args[0] === 'api' && args[1] === 'getCurrentUser') { | ||
| return JSON.stringify({ id: 'user-id' }); | ||
| } | ||
| if (args[0] === 'api' && args[1] === 'getSite') { | ||
| return JSON.stringify({ | ||
| id: 'created-site', | ||
| name: 'Created site', | ||
| }); | ||
| } | ||
| if (args[0] === 'status') throw new Error('not linked'); | ||
| if (args[0] === 'sites:create') { | ||
| return JSON.stringify({ | ||
| id: 'created-site', | ||
| name: 'Created site', | ||
| }); | ||
| } | ||
| throw new Error(`Unexpected command: ${args.join(' ')}`); | ||
| }, | ||
| ); | ||
| const persistSettings = vi.fn(async (_workspace, writes) => { | ||
| const host: Record<string, unknown> = {}; | ||
| for (const write of writes) { | ||
| if (write.key === 'artifact.host.uploadCommand') { | ||
| host['uploadCommand'] = write.value; | ||
| } | ||
| if (write.key === 'artifact.host.urlFromCommandOutput') { | ||
| host['urlFromCommandOutput'] = write.value; | ||
| } | ||
| } | ||
| settingsByWorkspace[workspaceCwd] = { host }; | ||
| }); | ||
| const app = express(); | ||
| app.use(express.json()); | ||
| registerWorkspaceArtifactPublishRoutes(app, { | ||
| getPrimaryRuntime: () => primary, | ||
| sendBridgeError, | ||
| mutate: allowMutations, | ||
| runCommand: adaptTestRunner(runCommand), | ||
| persistSettings, | ||
| }); | ||
|
|
||
| const response = await request(app) | ||
| .post('/workspace/artifact/netlify/setup') | ||
| .send({ action: 'prepare' }); | ||
|
|
||
| expect( | ||
| response.status, | ||
| `setup returned ${JSON.stringify(response.body)}`, | ||
| ).toBe(200); | ||
| expect(response.body.setup).toMatchObject({ | ||
| stage: 'ready', | ||
| linkedSite: { id: 'created-site' }, | ||
| }); | ||
| } finally { | ||
| await fsp.rm(workspaceCwd, { recursive: true, force: true }); | ||
| } | ||
| }, | ||
| ); | ||
| }); | ||
|
|
||
| describe('multi-provider artifact setup', () => { | ||
|
|
@@ -1893,6 +2007,37 @@ describe('POST /workspace/artifact/publish', () => { | |
| .mocked(readyNetlify) | ||
| .mock.calls.some(([, args]) => args[0] === 'status'), | ||
| ).toBe(false); | ||
| expect( | ||
| vi | ||
| .mocked(readyNetlify) | ||
| .mock.calls.some( | ||
| ([, args]) => args[0] === 'api' && args[1] === 'updateSite', | ||
| ), | ||
| ).toBe(false); | ||
| expect(response.body).toMatchObject({ | ||
| provider: 'netlify', | ||
| url: 'https://preview.example.com/report', | ||
| }); | ||
| }); | ||
|
|
||
| it('strips site protection only for the managed Netlify site', async () => { | ||
| const readBytesWindow = windowReader(HTML); | ||
| const primary = runtime('primary', '/workspace', { | ||
| primary: true, | ||
| readBytesWindow, | ||
| }); | ||
| mockSettings({ | ||
| '/workspace': { | ||
| host: NETLIFY_HOST, | ||
| share: { netlify: { siteId: 'site-id' } }, | ||
| }, | ||
| }); | ||
|
|
||
| const response = await request(makePrimaryApp(primary)) | ||
| .post('/workspace/artifact/publish') | ||
| .send({ path: 'out/report.html', provider: 'netlify' }); | ||
|
|
||
| expect(response.status).toBe(200); | ||
| const updateCall = vi | ||
| .mocked(readyNetlify) | ||
| .mock.calls.find( | ||
|
|
@@ -1914,10 +2059,6 @@ describe('POST /workspace/artifact/publish', () => { | |
| expect(mocked.hostPublish.mock.invocationCallOrder[0]).toBeLessThan( | ||
| vi.mocked(readyNetlify).mock.invocationCallOrder[updateCallIndex]!, | ||
| ); | ||
| expect(response.body).toMatchObject({ | ||
| provider: 'netlify', | ||
| url: 'https://preview.example.com/report', | ||
| }); | ||
| }); | ||
|
|
||
| it('publishes through a pinned Cloudflare Pages project', async () => { | ||
|
|
@@ -2514,6 +2655,7 @@ describe('POST /workspace/artifact/publish', () => { | |
| mockSettings({ | ||
| '/workspace': { | ||
| host: NETLIFY_HOST, | ||
| share: { netlify: { siteId: 'site-id' } }, | ||
| }, | ||
| }); | ||
| const protectedNetlify: ArtifactRouteCommandRunner = vi.fn( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -446,6 +446,27 @@ function providerEnv(runtime: WorkspaceRuntime): NodeJS.ProcessEnv { | |
| delete env['CLOUDFLARE_ACCOUNT_ID']; | ||
| delete env['VERCEL_ORG_ID']; | ||
| delete env['VERCEL_PROJECT_ID']; | ||
| delete env['CLOUDFLARE_API_TOKEN']; | ||
| delete env['CLOUDFLARE_API_BASE_URL']; | ||
| delete env['CF_API_BASE_URL']; | ||
|
Comment on lines
+457
to
+459
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deferred by maintainer decision (round-6 summary comment): the env-denylist class keeps yielding bypasses and the real fix is an allowlist-constructed provider env — tracked as a follow-up, not half-fixed here. Re-posted at round 6 (3876242226), round 7 (3879310205) and round 8 (3882827680). This round was scoped to the rejected verification gate only. 中文说明按维护者决定延后(第 6 轮总结评论):环境拒绝清单类问题不断产生绕过,真正的修复是白名单构造的 provider 环境——作为后续跟进,不在此处做半吊子修复。已在第 6 轮(3876242226)、第 7 轮(3879310205)、第 8 轮(3882827680)重新发布。本轮只处理被拒的验证门禁。 |
||
| delete env['VERCEL_TOKEN']; | ||
| delete env['NETLIFY_AUTH_TOKEN']; | ||
| delete env['NETLIFY_API_URL']; | ||
| delete env['NETLIFY_SITE_ID']; | ||
|
Comment on lines
+461
to
+463
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deferred by maintainer decision (round-6 summary comment): the env-denylist class keeps yielding bypasses and the real fix is an allowlist-constructed provider env — tracked as a follow-up, not half-fixed here. Re-posted at round 7 (3879310205) and round 8 (3882827680). This round was scoped to the rejected verification gate only. 中文说明按维护者决定延后(第 6 轮总结评论):环境拒绝清单类问题不断产生绕过,真正的修复是白名单构造的 provider 环境——作为后续跟进,不在此处做半吊子修复。已在第 7 轮(3879310205)与第 8 轮(3882827680)重新发布。本轮只处理被拒的验证门禁。
Comment on lines
+461
to
+463
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deferred by maintainer decision (round-6 summary comment): the env-denylist class is tracked as a follow-up (allowlist-constructed provider env), not half-fixed in this PR. Re-posted at round 8 (3882827680) with the config-dir entrance. This round was scoped to the rejected verification gate only. 中文说明按维护者决定延后(第 6 轮总结评论):环境拒绝清单类问题作为后续跟进(白名单构造的 provider 环境),不在本 PR 中做半吊子修复。已在第 8 轮(3882827680)连同配置目录入口重新发布。本轮只处理被拒的验证门禁。
Comment on lines
+461
to
+463
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deferred by maintainer decision (round-6 summary comment): the env-denylist class is tracked as a follow-up (allowlist-constructed provider env), not half-fixed in this PR. Still open at the current head (8f3ccb8). This round was scoped to the rejected verification gate only. 中文说明按维护者决定延后(第 6 轮总结评论):环境拒绝清单类问题作为后续跟进(白名单构造的 provider 环境),不在本 PR 中做半吊子修复。在当前头(8f3ccb8ae6)仍开放。本轮只处理被拒的验证门禁。 |
||
| // Restore the daemon's credential-store locations instead of deleting | ||
| // them: logins performed by this daemon live under those paths. | ||
|
Comment on lines
+464
to
+465
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deferred by maintainer decision (round-6 summary comment): per-workspace credential isolation is a rewrite of the credential-home handling, tracked as a follow-up rather than half-fixed here. Re-posted at round 7 (3879310218) and round 8 (3882827685). This round was scoped to the rejected verification gate only. 中文说明按维护者决定延后(第 6 轮总结评论):按工作区隔离凭据是对凭据目录处理的重写,作为后续跟进,不在此处做半吊子修复。已在第 7 轮(3879310218)与第 8 轮(3882827685)重新发布。本轮只处理被拒的验证门禁。 |
||
| for (const key of [ | ||
| 'XDG_DATA_HOME', | ||
|
Comment on lines
+466
to
+467
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deferred by maintainer decision (round-6 summary comment): per-workspace credential isolation is tracked as a follow-up, not half-fixed in this PR. Re-posted at round 8 (3882827685). This round was scoped to the rejected verification gate only. 中文说明按维护者决定延后(第 6 轮总结评论):按工作区隔离凭据作为后续跟进,不在本 PR 中做半吊子修复。已在第 8 轮(3882827685)重新发布。本轮只处理被拒的验证门禁。 |
||
| 'XDG_STATE_HOME', | ||
|
Comment on lines
+466
to
+468
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deferred by maintainer decision (round-6 summary comment): per-workspace credential isolation is tracked as a follow-up, not half-fixed in this PR. Still open at the current head (8f3ccb8). This round was scoped to the rejected verification gate only. 中文说明按维护者决定延后(第 6 轮总结评论):按工作区隔离凭据作为后续跟进,不在本 PR 中做半吊子修复。在当前头(8f3ccb8ae6)仍开放。本轮只处理被拒的验证门禁。 |
||
| 'XDG_CACHE_HOME', | ||
| 'XDG_CONFIG_HOME', | ||
| 'APPDATA', | ||
| 'LOCALAPPDATA', | ||
| ]) { | ||
| const daemonValue = process.env[key]; | ||
| if (daemonValue === undefined) delete env[key]; | ||
| else env[key] = daemonValue; | ||
| } | ||
| return env; | ||
| } | ||
|
|
||
|
|
@@ -1579,8 +1600,9 @@ async function assertLinkBoundary(runtime: WorkspaceRuntime): Promise<void> { | |
| await fsp.stat(path.join(current, '.git')); | ||
|
Comment on lines
+1648
to
+1650
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Still open at the current head (8f3ccb8); no fix has landed for the remaining boundary geometries (no-.git tree; lexical vs realpath divergence). Re-posted at round 8 (3882827711). Deferred to the next round under the budget warning; see the round report for the gate-rejection verification. 中文说明在当前头(8f3ccb8ae6)仍开放;剩余边界几何(无 .git 树;词法与 realpath 分叉)尚无修复落地。已在第 8 轮(3882827711)重新发布。按预算警告延后至下一轮;门禁拒绝的验证见本轮报告。 |
||
| repositoryRoot = current; | ||
| break; | ||
| } catch (error) { | ||
| if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; | ||
| } catch { | ||
| // Only a discovered .git proves nesting; an unreadable entry proves | ||
| // nothing, and rethrowing turns transient fs errors into setup 500s. | ||
| } | ||
| const parent = path.dirname(current); | ||
| if (parent === current) break; | ||
|
|
@@ -2948,7 +2970,9 @@ async function handlePublish( | |
| `Could not publish the artifact through ${selectedProvider}. Try again.`, | ||
| ); | ||
| }); | ||
| if (selectedProvider === 'netlify') { | ||
| if (selectedProvider === 'netlify' && settings.netlify.siteId) { | ||
|
qqqys marked this conversation as resolved.
Outdated
|
||
| // Only the dedicated site this flow created may have protection | ||
| // stripped; a host-configured site keeps its password/SSO settings. | ||
| await makeSitePublic( | ||
| resolvedPublisher.command, | ||
| resolvedPublisher.targetId, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Deferred: Suggestion-level witness gap (XDG/APPDATA restore loop only witnessed for XDG_DATA_HOME against process.env), still open. This round was scoped to the rejected verification gate under the budget warning, so non-essential work was not retried.
中文说明
延后:建议级见证缺口(XDG/APPDATA 恢复循环只有 XDG_DATA_HOME 对着 process.env 有见证),仍开放。本轮在预算警告下只处理被拒的验证门禁,未重试非必要工作。