Skip to content

Commit 2268455

Browse files
committed
수정: 교차 플랫폼 릴리스 게이트 복구
스킬 자원 해시를 줄바꿈에 독립적으로 만들고 준비형 자산 경계를 명시했다. 공식 CPython 소스 잠금과 Pages COI 부트스트랩을 현재 배포 계약에 맞췄다. 검증: npm test, 계약 36개, 무헤더 예제 10개, 스킬 및 repoGraph 게이트
1 parent 5a554a4 commit 2268455

13 files changed

Lines changed: 125 additions & 29 deletions

File tree

.github/workflows/pages.yml

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
# GitHub Pages 데모 배포. 빌드 없음(네이티브 ESM): 저장소 파일을 그대로 조립해 올린다.
2-
# GH Pages는 응답 헤더를 못 달지만 실측으로 경로를 확보했다(pythonMachine 캠페인):
3-
# - SAB 없는 데모(machine/terminal/basic)는 헤더 없이 그대로 돈다(noCoiProbe 7/7).
4-
# - SAB 필요한 processOs는 pyprocSw(?coi=1) 헤더 주입 + 1회 새로고침으로 연다(swCoiProbe 4/4).
2+
# GH Pages는 응답 헤더를 직접 달 수 없으므로 데모 전용 Service Worker가 COOP, COEP를
3+
# 주입하고 첫 방문에 한 번만 다시 연다. 패키지 소비자는 README의 서버 헤더 계약을 따른다.
54
name: pages
65

76
on:
@@ -33,18 +32,18 @@ jobs:
3332
# 의존성 0이라 여기서 도는 비용이 거의 없다(브라우저 게이트는 ci job이 본다).
3433
- run: npm ci
3534
- run: npm test
36-
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
37-
with:
38-
path: vendor/pyodide
39-
key: pyodide-${{ hashFiles('src/runtime/pyodideDistribution.js', 'scripts/assetCatalog.json') }}
40-
- run: npm run fetch:engine
41-
- name: 데모 사이트 조립 (예제 + 라이브러리 + 브랜드 자산 + SW 루트 사본 + 랜딩 루트 승격)
35+
- name: GitHub Pages 무헤더 환경에서 데모 COI 부트스트랩 검증
36+
run: npm run test:examples
37+
env:
38+
PYPROC_BROWSER: /usr/bin/google-chrome
39+
PYPROC_NO_COI: "1"
40+
- name: 데모 사이트 조립 (예제 + 소유 엔진 + 브랜드 자산 + COI 진입점 + 랜딩 루트 승격)
4241
run: |
4342
mkdir -p _site
4443
# assets: 파비콘·헤더 로고가 참조하는 브랜드 마크 정본(랜딩은 assets/, 예제는 ../assets/).
45-
cp -r examples src assets vendor _site/
44+
cp -r examples src assets _site/
4645
cp index.js index.d.ts LICENSE _site/
47-
cp src/capabilities/pyprocSw.js _site/pyprocSw.js
46+
cp coiServiceWorker.js _site/coiServiceWorker.js
4847
# 랜딩은 examples/index.html이 정본이고 배포 루트로 승격된다(상대 경로가 루트 기준).
4948
mv _site/examples/index.html _site/index.html
5049
- uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3

coiServiceWorker.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// coiServiceWorker.js - 정적 데모 응답에 cross-origin isolation 헤더를 붙인다.
2+
3+
self.addEventListener("install", () => self.skipWaiting());
4+
self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
5+
6+
self.addEventListener("fetch", (event) => {
7+
if (event.request.method !== "GET") return;
8+
event.respondWith(fetch(event.request).then((response) => {
9+
if (response.type === "opaque" || response.type === "opaqueredirect" || response.status === 0) return response;
10+
const headers = new Headers(response.headers);
11+
if (["document", "worker", "sharedworker"].includes(event.request.destination)) {
12+
headers.set("Cross-Origin-Opener-Policy", "same-origin");
13+
headers.set("Cross-Origin-Embedder-Policy", "require-corp");
14+
}
15+
headers.set("Cross-Origin-Resource-Policy", "cross-origin");
16+
return new Response(response.body, {
17+
status: response.status,
18+
statusText: response.statusText,
19+
headers,
20+
});
21+
}));
22+
});

examples/coiBootstrap.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// coiBootstrap.js - GitHub Pages 데모만 위한 첫 방문 COI 부트스트랩.
2+
3+
const RELOAD_MARKER = "pyprocCoiReload";
4+
5+
export async function ensureCrossOriginIsolation() {
6+
if (globalThis.crossOriginIsolated && typeof globalThis.SharedArrayBuffer === "function") {
7+
sessionStorage.removeItem(RELOAD_MARKER);
8+
return Object.freeze({ ready: true, source: "headers" });
9+
}
10+
if (!("serviceWorker" in navigator)) {
11+
throw new Error("This demo needs cross-origin isolation and Service Worker support.");
12+
}
13+
const script = new URL("../coiServiceWorker.js", import.meta.url);
14+
const scope = new URL("../", import.meta.url);
15+
await navigator.serviceWorker.register(script, { scope: scope.pathname });
16+
await navigator.serviceWorker.ready;
17+
if (!navigator.serviceWorker.controller) {
18+
if (sessionStorage.getItem(RELOAD_MARKER) === "1") {
19+
throw new Error("The demo Service Worker did not take control after reload.");
20+
}
21+
sessionStorage.setItem(RELOAD_MARKER, "1");
22+
location.reload();
23+
await new Promise(() => {});
24+
}
25+
if (!globalThis.crossOriginIsolated || typeof globalThis.SharedArrayBuffer !== "function") {
26+
throw new Error("The controlled demo page is not cross-origin isolated.");
27+
}
28+
sessionStorage.removeItem(RELOAD_MARKER);
29+
return Object.freeze({ ready: true, source: "serviceWorker" });
30+
}

examples/heroConsole.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
// heroConsole.js - live landing proof built only on the public owned-kernel API.
2+
import { ensureCrossOriginIsolation } from "./coiBootstrap.js";
3+
4+
await ensureCrossOriginIsolation();
25

36
const PROMPT = '<span class="ok">&gt;&gt;&gt;</span>';
47
const dim = (text) => `<span class="dim">${text}</span>`;

examples/ownedDemo.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import { ensureCrossOriginIsolation } from "./coiBootstrap.js";
12
import { boot, open } from "../index.js";
23

4+
await ensureCrossOriginIsolation();
5+
36
const output = document.getElementById("output");
47
const status = document.getElementById("status");
58
const runButton = document.getElementById("run");

scripts/engineBuilder/engineBuildLock.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
"version": "3.14.6",
88
"tagObject": "8594736f5057fdc979d42d2135895d56274589a8",
99
"commit": "c63aec69bd59c55314c06c23f4c22c03de76fe45",
10-
"url": "https://api.github.com/repos/python/cpython/tarball/c63aec69bd59c55314c06c23f4c22c03de76fe45",
11-
"archiveSha256": "c6b8133e056dd5992782b84eb7269a9ac7402f4c35b2f8ecd333cba9fc630d41"
10+
"url": "https://www.python.org/ftp/python/3.14.6/Python-3.14.6.tgz",
11+
"archiveSha256": "74d0d71d0600e477651a077101d6e62d1e2e69b8e992ba18c993dd643b7ba222"
1212
},
1313
"wasiSdk": {
1414
"version": "24.0",

scripts/skillOs/common.mjs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { createHash } from "node:crypto";
2-
import { isAbsolute, relative, resolve, sep } from "node:path";
2+
import { extname, isAbsolute, relative, resolve, sep } from "node:path";
3+
4+
const PORTABLE_TEXT_EXTENSIONS = new Set([
5+
".css", ".html", ".js", ".json", ".jsonl", ".md", ".mjs", ".py", ".sh", ".toml", ".txt", ".yaml", ".yml",
6+
]);
7+
const UTF8 = new TextDecoder("utf-8", { fatal: true });
38

49
export class SkillOsError extends Error {
510
constructor(code, message, context = {}) {
@@ -15,6 +20,15 @@ export const utf8Compare = (left, right) => Buffer.from(String(left)).compare(Bu
1520
export const slash = (value) => String(value).replaceAll("\\", "/");
1621
export const canonicalJson = (value) => `${JSON.stringify(value, null, 2)}\n`;
1722

23+
export function portableResourceBytes(path, bytes) {
24+
const source = Buffer.from(bytes);
25+
if (!PORTABLE_TEXT_EXTENSIONS.has(extname(String(path)).toLowerCase())) return source;
26+
let text;
27+
try { text = UTF8.decode(source); }
28+
catch (error) { throw new SkillOsError("SKILL_STRUCTURE_INVALID", `text resource is not valid UTF-8: ${path}`, { cause: error }); }
29+
return Buffer.from(text.replaceAll("\r\n", "\n").replaceAll("\r", "\n"));
30+
}
31+
1832
export function containedPath(root, relativePath, code = "SKILL_REFERENCE_ESCAPE") {
1933
const text = String(relativePath || "");
2034
if (!text || isAbsolute(text) || /^[A-Za-z]:/u.test(text) || text.startsWith("\\\\")

scripts/skillOs/skillParser.mjs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { lstat, readFile, realpath, readdir } from "node:fs/promises";
22
import { basename, dirname, relative, resolve } from "node:path";
33

4-
import { SkillOsError, containedPath, sha256, slash, stableName, utf8Compare } from "./common.mjs";
4+
import { SkillOsError, containedPath, portableResourceBytes, sha256, slash, stableName, utf8Compare } from "./common.mjs";
55

66
const BODY_LIMIT = 96 * 1024;
77
const REFERENCE_LIMIT = 256 * 1024;
@@ -112,7 +112,8 @@ async function resourceFiles(skillRoot, folder, limit) {
112112
if ((folder === "references" || folder === "scripts" || folder === "agents") && bytes.includes(0)) {
113113
fail("SKILL_STRUCTURE_INVALID", `text resource contains NUL: ${relativePath}`);
114114
}
115-
output.push(Object.freeze({ path: relativePath, bytes: bytes.byteLength, sha256: sha256(bytes) }));
115+
const portable = portableResourceBytes(relativePath, bytes);
116+
output.push(Object.freeze({ path: relativePath, bytes: portable.byteLength, sha256: sha256(portable) }));
116117
}
117118
}
118119
}
@@ -131,6 +132,7 @@ export async function parseSkill(skillPath, { skillsRoot = dirname(skillPath) }
131132
try { text = TEXT_DECODER.decode(bytes); }
132133
catch { fail("SKILL_STRUCTURE_INVALID", "SKILL.md is not valid UTF-8"); }
133134
text = text.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
135+
const portable = Buffer.from(text);
134136
const frontmatter = parseFrontmatter(text);
135137
if (!stableName(frontmatter.name)) fail("SKILL_FRONTMATTER_INVALID", `invalid skill name: ${frontmatter.name}`);
136138
if (frontmatter.name !== basename(directory)) fail("SKILL_NAME_MISMATCH", `${frontmatter.name} does not match ${basename(directory)}`);
@@ -149,8 +151,8 @@ export async function parseSkill(skillPath, { skillsRoot = dirname(skillPath) }
149151
]);
150152
if (references.length > 8) fail("SKILL_RESOURCE_LIMIT", "a skill may declare at most eight direct references");
151153
return Object.freeze({ name: frontmatter.name, description: frontmatter.description,
152-
path: slash(relative(resolve(skillsRoot), path)), sourcePath: path, bytes: bytes.byteLength,
153-
sha256: sha256(bytes), normalizedSha256: sha256(Buffer.from(text)), headings,
154+
path: slash(relative(resolve(skillsRoot), path)), sourcePath: path, bytes: portable.byteLength,
155+
sha256: sha256(portable), normalizedSha256: sha256(portable), headings,
154156
references, scripts, assets, agents });
155157
}
156158

scripts/skillOs/skillReader.mjs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { lstat, readFile, realpath } from "node:fs/promises";
22
import { dirname, relative, resolve } from "node:path";
33

4-
import { SkillOsError, containedPath, sha256, slash } from "./common.mjs";
4+
import { SkillOsError, containedPath, portableResourceBytes, sha256, slash } from "./common.mjs";
55
import { findCatalogSkill, readSkillCatalog } from "./skillCatalog.mjs";
66

77
const BODY_LIMIT = 96 * 1024;
@@ -50,7 +50,8 @@ export async function readSkillResource(skillsRoot, catalog, request) {
5050
}
5151
const limit = declared.kind === "body" ? BODY_LIMIT : REFERENCE_LIMIT;
5252
if (stat.size > limit) throw new SkillOsError("SKILL_RESOURCE_LIMIT", `${relativePath} exceeds read limit`);
53-
const bytes = await readFile(path);
53+
const sourceBytes = await readFile(path);
54+
const bytes = portableResourceBytes(relativePath, sourceBytes);
5455
if (bytes.byteLength !== declared.bytes || sha256(bytes) !== declared.sha256) {
5556
throw new SkillOsError("SKILL_READ_STALE", `resource bytes do not match catalog: ${relativePath}`);
5657
}

skills/catalog.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"format": "pyproc-skill-catalog",
33
"version": 1,
4-
"generatedFromDigest": "sha256:3700caf89256d04ac10458b34761ca874c11324a59279a67c77786ee61e4c627",
5-
"catalogDigest": "sha256:3050266485b8f8473d1f988db32a6229d3b166e952c63f76d0631c9acf0a8e1f",
4+
"generatedFromDigest": "sha256:98821f660c60b50575bba6710700605cf17f687c215785021159b62f95174be3",
5+
"catalogDigest": "sha256:02b3fcd7d7f3af12ed97f74ade8f8912c1b0ef72b3b4ed08c6c0cf7683cd9890",
66
"skills": [
77
{
88
"name": "automate-browser-with-pyproc",
@@ -349,8 +349,8 @@
349349
},
350350
{
351351
"path": "skills/ship-pyproc/references/demo-hosting.md",
352-
"bytes": 765,
353-
"sha256": "sha256:293510dad4f0261ad44ab0748f2ddf419aca42887c3967e358ca8fbc00ce9f52"
352+
"bytes": 1132,
353+
"sha256": "sha256:36e2122cdb3c06b93f2b91375cabf9c271691977d096f005ad6ccb7a805b0ced"
354354
},
355355
{
356356
"path": "skills/ship-pyproc/references/release.md",

0 commit comments

Comments
 (0)