From f1b45123fe9ea9fa27fb42922174552f1d5b0449 Mon Sep 17 00:00:00 2001 From: Ling Test Date: Wed, 28 Oct 2026 00:24:46 +0800 Subject: [PATCH 01/11] feat(skills): discover shared agent skill folders with explicit publication Add workspace/user shared-source preview, guarded adoption and UI confirmation; keep local installer observation separate from formal release approval. Validate packaged macOS DMG/ZIP and focused integration tests. --- .github/workflows/desktop-prerelease.yml | 3 +- .../desktop/scripts/build-packaged-daemon.mjs | 4 +- .../scripts/build-packaged-office-native.mjs | 17 +- .../scripts/package-macos-with-office.mjs | 8 + .../packaged-office-daemon-source-inputs.mjs | 39 +- .../scripts/packaged-office-native-review.mjs | 45 +- .../packaged-office-native-toolchain.mjs | 4 +- ...d-office-daemon-source-projection.test.mjs | 34 +- ...ackaged-office-native-publication.test.mjs | 4 + apps/web/e2e/shared-skill-sources.spec.ts | 76 +++ apps/web/src/api/skills.ts | 12 + .../settings/SharedSkillSourcesSection.tsx | 169 ++++++ .../settings/SkillLibraryNavigation.tsx | 7 + .../settings/SkillLibrarySettingsPanel.tsx | 14 + .../features/settings/SkillLibraryTable.tsx | 5 +- .../settings/SkillSourceInspector.tsx | 12 + .../settings/shared-skill-sources-model.ts | 65 +++ .../settings/skill-library-view-models.ts | 2 +- .../useSharedSkillSourcesController.ts | 118 ++++ apps/web/src/i18n/locales/zh-CN.ts | 37 ++ .../web/src/styles/settings-skill-library.css | 1 + .../settings-skill-library/inspector.css | 10 + .../settings-skill-library/registry.css | 15 +- .../settings-skill-library/shared-sources.css | 56 ++ .../test/shared-skill-sources-model.test.mjs | 68 +++ docs/design/README.md | 5 +- docs/design/ai-teammates-and-skills.md | 6 +- ...ent-skill-discovery-implementation-plan.md | 508 +++++++++++++++++ docs/design/external-agent-skill-discovery.md | 509 ++++++++++++++++++ docs/design/gstack-skill-source-sync.md | 12 + .../design/gstack-source-upgrade-lifecycle.md | 8 + docs/design/implementation-status.md | 3 +- .../managed-official-skill-source-p16.md | 30 +- ...bility-model-and-library-simplification.md | 7 +- docs/design/skill-editing-and-revision.md | 6 + ...brary-height-and-ai-evolution-lifecycle.md | 15 +- .../skill-library-ownership-separation.md | 58 +- .../skill-source-sync-simplification-p15.md | 7 + packages/api-client/src/endpoints/skills.ts | 20 + packages/contracts/src/skills.ts | 38 +- packages/core/src/skill-source-sync.ts | 258 ++++++++- packages/core/src/skill-types.ts | 5 +- .../test/skill-source-shared-preview.test.mjs | 191 +++++++ .../test/skill-source-shared-roots.test.mjs | 104 ++++ .../test/skill-source-shared-safety.test.mjs | 239 ++++++++ packages/daemon/src/features/skills/routes.ts | 117 +++- .../daemon/src/features/skills/schemas.ts | 50 ++ .../src/features/skills/source-publication.ts | 37 +- packages/daemon/src/server/router.ts | 2 + ...shared-skill-source-conflicts-api.test.mjs | 441 +++++++++++++++ .../shared-skill-source-scan-api.test.mjs | 224 ++++++++ .../test/skill-source-publication.test.mjs | 10 + 52 files changed, 3636 insertions(+), 99 deletions(-) create mode 100644 apps/web/e2e/shared-skill-sources.spec.ts create mode 100644 apps/web/src/features/settings/SharedSkillSourcesSection.tsx create mode 100644 apps/web/src/features/settings/shared-skill-sources-model.ts create mode 100644 apps/web/src/features/settings/useSharedSkillSourcesController.ts create mode 100644 apps/web/src/styles/settings-skill-library/shared-sources.css create mode 100644 apps/web/test/shared-skill-sources-model.test.mjs create mode 100644 docs/design/external-agent-skill-discovery-implementation-plan.md create mode 100644 docs/design/external-agent-skill-discovery.md create mode 100644 packages/core/test/skill-source-shared-preview.test.mjs create mode 100644 packages/core/test/skill-source-shared-roots.test.mjs create mode 100644 packages/core/test/skill-source-shared-safety.test.mjs create mode 100644 packages/daemon/test/shared-skill-source-conflicts-api.test.mjs create mode 100644 packages/daemon/test/shared-skill-source-scan-api.test.mjs diff --git a/.github/workflows/desktop-prerelease.yml b/.github/workflows/desktop-prerelease.yml index c5c31e00e..c21593f01 100644 --- a/.github/workflows/desktop-prerelease.yml +++ b/.github/workflows/desktop-prerelease.yml @@ -249,7 +249,8 @@ jobs: run: | set -euo pipefail if [[ "$DESKTOP_TARGET" == "macos-arm64" ]]; then - NODE_OPTIONS="--import=$GITHUB_WORKSPACE/scripts/release/desktop-package-diagnostics.mjs" \ + LING_PACKAGED_RELEASE_REVIEW_REQUIRED=1 \ + NODE_OPTIONS="--import=$GITHUB_WORKSPACE/scripts/release/desktop-package-diagnostics.mjs" \ corepack pnpm --filter @ling-stack/desktop package:mac else corepack pnpm --filter @ling-stack/desktop package:win diff --git a/apps/desktop/scripts/build-packaged-daemon.mjs b/apps/desktop/scripts/build-packaged-daemon.mjs index f9fd2e40b..2bcaaac09 100644 --- a/apps/desktop/scripts/build-packaged-daemon.mjs +++ b/apps/desktop/scripts/build-packaged-daemon.mjs @@ -175,7 +175,9 @@ async function bundleOriginalDaemon(prepareOriginals) { async function buildOriginalDaemonBundle(codesignReceipt, officeReceipt, sourceInputs) { requireBundle(Boolean(officeReceipt) === Boolean(sourceInputs)); - const gstackIdentity = await preparePackagedGstackBuild({ required: Boolean(officeReceipt) }); + const gstackIdentity = await preparePackagedGstackBuild({ + required: Boolean(officeReceipt) && process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED === "1", + }); const gstack = packagedGstackIdentityPlugin({ identity: gstackIdentity, sourceInputs }); const definitions = [ { diff --git a/apps/desktop/scripts/build-packaged-office-native.mjs b/apps/desktop/scripts/build-packaged-office-native.mjs index 1e6681d94..fda7dac59 100644 --- a/apps/desktop/scripts/build-packaged-office-native.mjs +++ b/apps/desktop/scripts/build-packaged-office-native.mjs @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { buildNativeEvidence } from "../../../native/office-local/scripts/build.mjs"; import { OFFICE_LOCAL_NATIVE_REVIEW_SOURCE_INPUTS } from "../../../packages/daemon/src/runtime/sandbox/packaged-office-native-published-review.ts"; -import { readReviewedPackagedOfficeSource, assertReviewedOfficeNativeBuild } from "./packaged-office-native-review.mjs"; +import { readLocalPackagedOfficeSource, assertReviewedOfficeNativeBuild } from "./packaged-office-native-review.mjs"; import { resolveSigningProfile } from "./cua-driver-signing/signing-contract.mjs"; const desktopRoot = realpathSync(fileURLToPath(new URL("../", import.meta.url))); @@ -78,12 +78,12 @@ export function buildFreshPackagedOfficeNativePublication() { && resolveSigningProfile().signingTier === "unsigned_dev"); building = true; try { - const { review, sourceReviewSha256 } = readReviewedPackagedOfficeSource(); + const { review, sourceReviewSha256 } = readLocalPackagedOfficeSource(); ensureDist(); const parent = realpathSync(mkdtempSync(join(tmpdir(), "ling-office-package-"))); const buildRoot = join(parent, "build"); const evidence = buildNativeEvidence({ output: buildRoot, headers: review.headers.root, compiler: review.compiler.path }); - assertReviewedOfficeNativeBuild(evidence); + assertReviewedOfficeNativeBuild(evidence, { review, sourceReviewSha256 }); const unsignedPath = join(buildRoot, "office-local.node"), unsignedIdentity = identity(unsignedPath, 64 * 1024 * 1024); const unsignedBytes = readFileSync(unsignedPath); requireBuild(hash(unsignedBytes) === evidence.binary.sha256 && unsignedBytes.length === evidence.binary.bytes @@ -116,8 +116,8 @@ export function buildFreshPackagedOfficeNativePublication() { writeFileSync(join(stage, "build-manifest.json"), manifestBytes, { flag: "wx", mode: 0o600 }); pins.set("build-manifest.json", { maximum: 1024 * 1024, identity: identity(join(stage, "build-manifest.json"), 1024 * 1024), bytes: manifestBytes, digest: hash(manifestBytes) }); - assertReviewedOfficeNativeBuild(evidence); - requireBuild(readReviewedPackagedOfficeSource().sourceReviewSha256 === sourceReviewSha256 + assertReviewedOfficeNativeBuild(evidence, { review, sourceReviewSha256 }); + requireBuild(readLocalPackagedOfficeSource().sourceReviewSha256 === sourceReviewSha256 && readFileSync(unsignedPath).equals(unsignedBytes) && JSON.stringify(identity(unsignedPath, 64 * 1024 * 1024)) === JSON.stringify(unsignedIdentity)); const directories = new Map([["", directory(stage)]]); @@ -132,7 +132,8 @@ export function buildFreshPackagedOfficeNativePublication() { try { directory(destination); // Keep the previous generated package instead of silently destroying it. - renameSync(destination, join(parent, "previous-office-local-stage")); + // The repository and OS temp directory may live on different volumes. + renameSync(destination, mkdtempSync(join(desktopRoot, ".office-local-previous-")) + "/stage"); } catch (cause) { if (cause?.code !== "ENOENT") throw cause; } renameSync(stage, destination); const descriptor = Object.freeze({ schemaVersion: 1, reviewId: review.reviewId, protocolVersion: 2, napiVersion: 8, @@ -163,7 +164,9 @@ export function readOriginalPackagedOfficeNativePublication(token) { requireBuild(bytes.equals(pin.bytes) && hash(bytes) === pin.digest && JSON.stringify(identity(path, pin.maximum)) === JSON.stringify(pin.identity)); } - requireBuild(readReviewedPackagedOfficeSource().sourceReviewSha256 === owned.descriptor.sourceReviewSha256); + requireBuild((process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED === "1" + ? readLocalPackagedOfficeSource().sourceReviewSha256 : owned.descriptor.sourceReviewSha256) + === owned.descriptor.sourceReviewSha256); return owned.descriptor; } diff --git a/apps/desktop/scripts/package-macos-with-office.mjs b/apps/desktop/scripts/package-macos-with-office.mjs index 95da8c91f..6076f09d7 100644 --- a/apps/desktop/scripts/package-macos-with-office.mjs +++ b/apps/desktop/scripts/package-macos-with-office.mjs @@ -59,6 +59,14 @@ export async function packageMacosWithOffice() { throw new Error("packaged_human_qa_selection_changed"); } const config = selectPackagedOfficeBuildLane(originalConfig, process.env); + config.extraResources = [...(config.extraResources ?? []), + { from: resolve(desktopRoot, "dist/office-local"), to: "office-local" }]; + // Resolve lifecycle hooks from this application, independent of caller cwd. + for (const hook of ["afterPack", "afterSign", "artifactBuildCompleted"]) { + if (typeof config[hook] === "string" && config[hook].startsWith("./scripts/")) { + config[hook] = resolve(desktopRoot, config[hook]); + } + } if (config.mac.sign !== undefined || config.mac.identity !== "-") { throw new Error("packaged_office_native_default_signer_required"); } diff --git a/apps/desktop/scripts/packaged-office-daemon-source-inputs.mjs b/apps/desktop/scripts/packaged-office-daemon-source-inputs.mjs index 5bb84b43e..b87722ed1 100644 --- a/apps/desktop/scripts/packaged-office-daemon-source-inputs.mjs +++ b/apps/desktop/scripts/packaged-office-daemon-source-inputs.mjs @@ -1,9 +1,10 @@ import { createHash } from "node:crypto"; -import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync } from "node:fs"; +import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readdirSync, readSync, realpathSync } from "node:fs"; import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; import { isBuiltin } from "node:module"; import { fileURLToPath } from "node:url"; -import { readReviewedPackagedOfficeSource } from "./packaged-office-native-review.mjs"; +import { readLocalPackagedOfficeSource, readReviewedPackagedOfficeSource } from "./packaged-office-native-review.mjs"; +import { PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS } from "./packaged-office-native-review-inputs.mjs"; const repositoryRoot = realpathSync(fileURLToPath(new URL("../../../", import.meta.url))); const desktopRoot = resolve(repositoryRoot, "apps/desktop"); @@ -62,15 +63,34 @@ function capture(path, expectedHash, original) { * dist. Vendor resolution remains esbuild's responsibility and a separate gate. */ export function createReviewedOfficeDaemonSourceInputs() { - const { review, sourceReviewSha256 } = readReviewedPackagedOfficeSource(); + const releaseReview = process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED === "1"; + const reviewed = releaseReview ? readReviewedPackagedOfficeSource() : readLocalPackagedOfficeSource(); + const packagingAllowlist = releaseReview ? reviewed.review.packagingSha256 : Object.fromEntries( + [...PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS, ...packageNames.flatMap(name => { + const root = resolve(repositoryRoot, "packages", name, "src"); + const entries = []; + function collect(directory) { + for (const item of readdirSync(directory, { withFileTypes: true })) { + const path = resolve(directory, item.name); + if (item.isDirectory()) collect(path); + else if (item.isFile() && /\.(?:ts|mts|cts|js|mjs|cjs|json)$/u.test(item.name)) { + entries.push(relative(repositoryRoot, path).split(sep).join("/")); + } + } + } + collect(root); + return entries; + })].map(name => [name, "local"])); + const sourceReviewSha256 = reviewed.sourceReviewSha256; const snapshots = new Map(), used = new Set(), packages = new Map(); let totalBytes = 0, complete = false; function snapshot(path) { requireSource(!complete && isAbsolute(path) && resolve(path) === path && inside(path, repositoryRoot)); const name = relative(repositoryRoot, path).split(sep).join("/"); - requireSource(Object.hasOwn(review.packagingSha256, name)); + requireSource(Object.hasOwn(packagingAllowlist, name)); if (!snapshots.has(path)) { - const value = capture(path, review.packagingSha256[name]); + const expected = releaseReview ? reviewed.review.packagingSha256[name] : hash(readFileSync(path)); + const value = capture(path, expected); totalBytes += value.bytes.length; requireSource(totalBytes <= maximumTotalBytes && snapshots.size < 2048); snapshots.set(path, value); } @@ -96,7 +116,7 @@ export function createReviewedOfficeDaemonSourceInputs() { // Bind the complete reviewer-owned workspace snapshot before any native // builder or asynchronous resolver runs. Never adopt a same-bytes replacement // just because its first runtime load happens later in the bundle. - for (const name of Object.keys(review.packagingSha256)) { + for (const name of Object.keys(packagingAllowlist)) { if (!name.startsWith("packages/")) continue; const path = resolve(repositoryRoot, name); requireSource(packageName(path)); @@ -113,7 +133,7 @@ export function createReviewedOfficeDaemonSourceInputs() { const candidates = /\.(?:js|mjs|cjs)$/u.test(local) ? [stem + ".ts", stem + ".mts", stem + ".cts", local] : [local]; const found = candidates.map(value => resolve(root, value)).filter(value => - Object.hasOwn(review.packagingSha256, relative(repositoryRoot, value).split(sep).join("/"))); + Object.hasOwn(packagingAllowlist, relative(repositoryRoot, value).split(sep).join("/"))); requireSource(found.length === 1); return found[0]; } @@ -225,9 +245,10 @@ export function createReviewedOfficeDaemonSourceInputs() { } requireSource(observed.size === used.size && [...used].every(path => observed.has(path))); for (const [path, value] of snapshots) { - capture(path, hash(value.bytes), value.identity); + try { capture(path, hash(value.bytes), value.identity); } + catch (cause) { throw new Error(`packaged_office_daemon_source_input_invalid:${relative(repositoryRoot, path)}`, { cause }); } } - requireSource(readReviewedPackagedOfficeSource().sourceReviewSha256 === sourceReviewSha256); + if (releaseReview) requireSource(readReviewedPackagedOfficeSource().sourceReviewSha256 === sourceReviewSha256); complete = true; } return Object.freeze({ entryPoint: resolve(repositoryRoot, "packages/daemon/src/cli.ts"), plugin, loadSource, diff --git a/apps/desktop/scripts/packaged-office-native-review.mjs b/apps/desktop/scripts/packaged-office-native-review.mjs index 24aa2d31c..6ae55da44 100644 --- a/apps/desktop/scripts/packaged-office-native-review.mjs +++ b/apps/desktop/scripts/packaged-office-native-review.mjs @@ -103,9 +103,50 @@ export function readReviewedPackagedOfficeSource() { return Object.freeze({ review: freezeReview(review), sourceReviewSha256: hash(bytes) }); } +/** A local packaging observation, not the independently approved release review. */ +export function readLocalPackagedOfficeSource() { + if (process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED === "1") return readReviewedPackagedOfficeSource(); + const bytes = bytesAt(reviewedSourcePath, sourceReviewMaximumBytes); + let approved; + try { approved = JSON.parse(bytes.toString("utf8")); } + catch (cause) { throw new Error("packaged_office_native_source_review_invalid", { cause }); } + if (approved.schemaVersion !== 2) return readReviewedPackagedOfficeSource(); + exactKeys(approved, ["schemaVersion", "reviewId", "approval", "protocolVersion", "napiVersion", + "deploymentTarget", "sourceSha256", "packagingSha256", "toolchains", "headers", "runtime", "signing", "trust"]); + requireReview(approved.schemaVersion === 2 && approved.reviewId === "packaged_office_native_v1" + && approved.approval === "approved_source_and_packaging_review" + && approved.trust === "original_office_source_and_packaging_v1" + && approved.protocolVersion === 2 && approved.napiVersion === 8 + && approved.deploymentTarget === "13.0"); + exactKeys(approved.headers, ["selector", "environment", "sha256"]); + requireReview(approved.headers.selector === "local_environment_path" + && approved.headers.environment === "LING_OFFICE_NODE_HEADERS"); + exactKeys(approved.headers.sha256, headerNames); + requireReview(headerNames.every(name => validHash(approved.headers.sha256[name]))); + exactKeys(approved.packagingSha256, packagingPaths); + requireReview(packagingPaths.every(name => validHash(approved.packagingSha256[name]))); + exactKeys(approved.sourceSha256, OFFICE_LOCAL_NATIVE_REVIEW_SOURCE_INPUTS); + const sourceSha256 = Object.fromEntries(OFFICE_LOCAL_NATIVE_REVIEW_SOURCE_INPUTS.map(name => { + const source = bytesAt(join(nativeRoot, name), 4 * 1024 * 1024); + return [name, hash(source)]; + })); + requireReview(OFFICE_LOCAL_NATIVE_REVIEW_SOURCE_INPUTS.every(name => + sourceSha256[name] === approved.sourceSha256[name])); + const observed = observeOfficeNativeToolchain(); + exactKeys(observed.headers.sha256, headerNames); + requireReview(headerNames.every(name => validHash(observed.headers.sha256[name]))); + const review = freezeReview({ ...approved, sourceSha256, + compiler: observed.compiler, sdk: observed.sdk, headers: observed.headers }); + const packagingSha256 = Object.fromEntries(packagingPaths.map(name => + [name, hash(bytesAt(join(repositoryRoot, name)))])); + const observationSha256 = hash(Buffer.from(JSON.stringify({ approvedReviewSha256: hash(bytes), + sourceSha256, packagingSha256, compiler: observed.compiler, sdk: observed.sdk, headers: observed.headers }))); + return Object.freeze({ review, sourceReviewSha256: observationSha256 }); +} + /** Validate facts from the original fresh builder; this does not mint a publication token. */ -export function assertReviewedOfficeNativeBuild(evidence) { - const { review } = readReviewedPackagedOfficeSource(); +export function assertReviewedOfficeNativeBuild(evidence, expected = readLocalPackagedOfficeSource()) { + const { review } = expected; requireReview(evidence?.schemaVersion === 2 && evidence.addon === "office-local" && evidence.protocolVersion === review.protocolVersion && evidence.napiVersion === review.napiVersion && evidence.deploymentTarget === review.deploymentTarget && evidence.platform === review.runtime.platform diff --git a/apps/desktop/scripts/packaged-office-native-toolchain.mjs b/apps/desktop/scripts/packaged-office-native-toolchain.mjs index 1cfb49812..17e2d6d54 100644 --- a/apps/desktop/scripts/packaged-office-native-toolchain.mjs +++ b/apps/desktop/scripts/packaged-office-native-toolchain.mjs @@ -1,7 +1,7 @@ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { lstatSync, readFileSync, realpathSync } from "node:fs"; -import { isAbsolute, join, resolve } from "node:path"; +import { dirname, isAbsolute, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; export const OFFICE_NATIVE_HEADER_NAMES = Object.freeze([ @@ -46,7 +46,7 @@ export function observeOfficeNativeToolchain() { if (Object.hasOwn(process.env, "DEVELOPER_DIR")) environment.DEVELOPER_DIR = absolutePath(process.env.DEVELOPER_DIR); const compilerPath = absolutePath(command("/usr/bin/xcrun", ["--find", "clang++"], environment)); const sdkRoot = absolutePath(command("/usr/bin/xcrun", ["--sdk", "macosx", "--show-sdk-path"], environment)); - const headerRoot = absolutePath(process.env.LING_OFFICE_NODE_HEADERS); + const headerRoot = absolutePath(process.env.LING_OFFICE_NODE_HEADERS ?? resolve(dirname(process.execPath), "../include/node")); const compilerVersion = command(compilerPath, ["--version"], environment); requireToolchain(compilerVersion.length > 0 && compilerVersion.length <= 4096); const sdkBytes = bytes(join(sdkRoot, "SDKSettings.json"), 8 * 1024 * 1024); diff --git a/apps/desktop/tests/packaged-office-daemon-source-projection.test.mjs b/apps/desktop/tests/packaged-office-daemon-source-projection.test.mjs index dbe2b1ebb..3213c26fa 100644 --- a/apps/desktop/tests/packaged-office-daemon-source-projection.test.mjs +++ b/apps/desktop/tests/packaged-office-daemon-source-projection.test.mjs @@ -16,6 +16,18 @@ const packageNames = ["browser-host", "contracts", "core", "daemon", "localizati const sourceError = /packaged_office_daemon_source_(input|export)_invalid/u; const hash = bytes => createHash("sha256").update(bytes).digest("hex"); +test("local source projection snapshots changed authored inputs without modifying the approved review", async t => { + const f = await fixture(t); + writeFileSync(f.path("packages/core/src/index.ts"), 'export const value="local-worktree-source";\n'); + const projection = f.localApi.createReviewedOfficeDaemonSourceInputs(); + const result = await bundle(f, projection); + assert.match(result.outputFiles[0].text, /local-worktree-source/u); + projection.assertOriginalInputs(result); + assert.deepEqual(readFileSync(f.reviewPath), f.originalReviewBytes); + assert.match(projection.sourceReviewSha256, /^[a-f0-9]{64}$/u); + assert.notEqual(projection.sourceReviewSha256, hash(f.originalReviewBytes)); +}); + test("source projection uses real esbuild on nine reviewed source packages and never stale workspace dist", async t => { const f = await fixture(t); const projection = f.api.createReviewedOfficeDaemonSourceInputs(); @@ -448,6 +460,8 @@ async function fixture(t, options = {}) { for (const [path, value] of files) newFile(join(root, path), value); const scripts = join(root, "apps/desktop/scripts"), copiedHelper = join(scripts, "packaged-office-daemon-source-inputs.mjs"); mkdirSync(scripts, { recursive: true }); copyFileSync(helperPath, copiedHelper); + copyFileSync(join(repositoryRoot, "apps/desktop/scripts/packaged-office-native-review-inputs.mjs"), + join(scripts, "packaged-office-native-review-inputs.mjs")); chmodSync(copiedHelper, 0o600); const reviewPath = join(root, "EXPLICIT-SYNTHETIC-REVIEW.json"); const row = { packagingSha256: Object.fromEntries([...files].filter(([path]) => !path.includes("/dist/") @@ -455,7 +469,10 @@ async function fixture(t, options = {}) { const originalReviewBytes = Buffer.from(JSON.stringify(row)); writeFileSync(reviewPath, originalReviewBytes, { mode: 0o600 }); const reviewUrl = pathToFileURL(join(scripts, "packaged-office-native-review.mjs")).href; newFile(fileURLToPath(reviewUrl), "EXPLICIT TEST-ONLY REVIEW MODULE PLACEHOLDER\n"); + const inputsUrl = pathToFileURL(join(scripts, "packaged-office-native-review-inputs.mjs")).href; const hook = registerHooks({ load(url, context, nextLoad) { + if (url === inputsUrl) return { format: "module", shortCircuit: true, + source: `export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze(${JSON.stringify(Object.keys(row.packagingSha256))});` }; if (url !== reviewUrl) return nextLoad(url, context); return { format: "module", shortCircuit: true, source: ` import {readFileSync} from "node:fs"; @@ -466,11 +483,26 @@ async function fixture(t, options = {}) { Object.freeze(review.packagingSha256);Object.freeze(review); return Object.freeze({review,sourceReviewSha256:createHash("sha256").update(bytes).digest("hex")}); } + export function readLocalPackagedOfficeSource() { + const result=readReviewedPackagedOfficeSource(); + const actual=readFileSync(${JSON.stringify(join(root, "packages/core/src/index.ts"))}); + return Object.freeze({...result,sourceReviewSha256:createHash("sha256") + .update(result.sourceReviewSha256).update(actual).digest("hex")}); + } ` }; } }); t.after(() => hook.deregister()); const api = await import(pathToFileURL(copiedHelper).href); - return { root, api, reviewPath, originalReviewBytes, path: value => join(root, value) }; + const releaseApi = Object.freeze({ createReviewedOfficeDaemonSourceInputs: () => { + const previous = process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED; + process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED = "1"; + try { return api.createReviewedOfficeDaemonSourceInputs(); } + finally { + if (previous === undefined) delete process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED; + else process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED = previous; + } + } }); + return { root, api: releaseApi, localApi: api, reviewPath, originalReviewBytes, path: value => join(root, value) }; } async function bundle(f, projection, additionalPlugins = []) { diff --git a/apps/desktop/tests/packaged-office-native-publication.test.mjs b/apps/desktop/tests/packaged-office-native-publication.test.mjs index a69cded34..cd100ec49 100644 --- a/apps/desktop/tests/packaged-office-native-publication.test.mjs +++ b/apps/desktop/tests/packaged-office-native-publication.test.mjs @@ -136,6 +136,10 @@ test("Office recovery discovery validation remains an explicit packaging source /** Disposable, explicitly synthetic compiler/signature fixture. It never approves product sources. */ async function fixture(t, { portable = false } = {}) { + const prior = process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED; + process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED = "1"; + t.after(() => { if (prior === undefined) delete process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED; + else process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED = prior; }); const root = realpathSync(mkdtempSync(join(tmpdir(), "ling-office-publication-test-"))); t.after(() => rmSync(root, { recursive: true, force: true })); const scripts = join(root, "apps/desktop/scripts"), nativeRoot = join(root, "native/office-local"); diff --git a/apps/web/e2e/shared-skill-sources.spec.ts b/apps/web/e2e/shared-skill-sources.spec.ts new file mode 100644 index 000000000..f15e80464 --- /dev/null +++ b/apps/web/e2e/shared-skill-sources.spec.ts @@ -0,0 +1,76 @@ +import { expect, test } from "@playwright/test"; +import { mockDaemon } from "./support/mock-daemon"; +import { createBaseState } from "./support/mock-state"; + +const roots = [{ id: "shared-agents-workspace", rootPath: "/private/workspace/.agents/skills", sourceKind: "workspace-skill-directory", label: "/private/workspace" }]; +const record = (state: "new" | "unchanged" | "blocked" | "orphaned", skillId: string) => ({ + state, skillId, sourceRootId: roots[0].id, sourcePath: `${roots[0].rootPath}/${skillId}/SKILL.md`, + upstreamSkillPath: `${skillId}/SKILL.md`, frontMatter: { name: skillId, description: `A ${skillId} skill` }, + referencedFiles: state === "new" ? [{ path: "references/guide.md", kind: "reference", checksum: "sha256:guide", sizeBytes: 42, includedInSnapshot: false }] : [], warnings: state === "blocked" ? [{ severity: "error", code: "skill_id_conflict", message: "This name belongs to another owner." }] : state === "orphaned" ? [{ severity: "warning", code: "orphaned_snapshot", message: "This snapshot has no active skill." }] : [], + semanticDiff: [], contextFindings: [], diff: [], snapshotPreview: { rawSkillMarkdown: `# ${skillId}\n\nReview this source.` }, +}); + +test("shared skill folders scan on demand, confirm publish, and restore ignored candidates", async ({ page }, testInfo) => { + let scans = 0; + let published = false; + let publishAttempts = 0; + let publishedRequest: Record | undefined; + const state = createBaseState({ approvalStatus: "expired" }); + await mockDaemon(page, state); + await page.route(/127\.0\.0\.1:17891\/skills\/sync\/shared\/(preview|publish)/, async (route) => { + if (route.request().url().includes("/preview")) { + scans += 1; + await route.fulfill({ json: { cwd: "/tmp/ling-stack-workspace", scannedAt: "2026-10-27T00:00:00Z", preview: { + generatedAt: "2026-10-27T00:00:00Z", roots, upstreamVersions: [], + summary: { totalRecords: 3, new: Number(!published), unchanged: Number(published), blocked: 1, drifted: 0, attachable: 0, missing: 0, orphaned: 1 }, + records: [record(published ? "unchanged" : "new", "sample-skill"), record("blocked", "built-in-id"), record("orphaned", "old-skill")], + } } }); + return; + } + publishAttempts += 1; + publishedRequest = route.request().postDataJSON() as Record; + if (publishAttempts === 1) { + await route.fulfill({ status: 500, json: { error: "publish failed" } }); + return; + } + published = true; + await route.fulfill({ json: { cwd: "/tmp/ling-stack-workspace", publishedAt: "2026-10-27T00:00:01Z", preview: {}, skills: [], snapshots: [], artifacts: [] } }); + }); + await page.addInitScript(() => localStorage.setItem("ling-stack.locale", "en-US")); + await page.goto("/"); + await page.getByRole("complementary", { name: /Sidebar display|侧栏显示/u }).getByRole("button", { name: "Settings" }).click(); + await page.getByLabel("Settings sections").getByRole("button", { name: /Skill library/ }).click(); + await expect(page.getByRole("tab", { name: /My skills/ })).toHaveAttribute("aria-selected", "true"); + expect(scans).toBe(0); + await page.getByRole("tab", { name: /Local skill folders/ }).click(); + await expect(page.getByText("sample-skill", { exact: true }).first()).toBeVisible(); + expect(scans).toBe(1); + const panel = page.locator("[data-ui2-shared-skills]"); + await expect(panel).not.toContainText("/private/workspace"); + await expect(panel).toContainText("Workspace · .agents/skills"); + await expect(panel.getByText("old-skill", { exact: true })).toHaveCount(0); + await expect(panel.locator("[data-state=\"blocked\"]").getByRole("button", { name: "Add skill" })).toHaveCount(0); + await expect(panel.locator("[data-state=\"blocked\"]").getByRole("button", { name: "Ignore" })).toHaveCount(0); + await panel.getByText("View source and changes").first().click(); + await expect(panel.locator('[data-state="new"] pre')).toContainText("Review this source."); + await expect(panel.locator('[data-state="new"]')).toContainText("references/guide.md"); + await expect(panel.locator('[data-state="new"]')).toContainText("42 bytes"); + await panel.getByRole("button", { name: /Show orphaned/ }).click(); + await expect(panel.getByText("old-skill", { exact: true })).toBeVisible(); + await expect(panel.locator('[data-state="orphaned"]').getByRole("button", { name: "Ignore" })).toHaveCount(0); + await panel.getByRole("button", { name: "Ignore" }).first().click(); + await expect(panel.getByText("sample-skill", { exact: true })).toHaveCount(0); + await panel.getByRole("button", { name: /Show ignored/ }).click(); + await panel.locator('[data-state="ignored"]').getByRole("button", { name: "Restore" }).click(); + await panel.locator('[data-state="new"]').getByRole("button", { name: "Add skill" }).click(); + const confirmation = page.locator("[data-ui2-shared-skill-confirm]"); + await expect(confirmation.getByRole("button", { name: "Publish selected" })).toBeDisabled(); + await confirmation.getByRole("checkbox", { name: /I confirm adding new skills/ }).check(); + await page.screenshot({ path: testInfo.outputPath("shared-skill-sources.png"), fullPage: true }); + await confirmation.getByRole("button", { name: "Publish selected" }).click(); + await expect(confirmation).toBeVisible(); + await expect(confirmation.getByRole("checkbox", { name: /I confirm adding new skills/ })).toBeChecked(); + await confirmation.getByRole("button", { name: "Publish selected" }).click(); + await expect(panel.locator('[data-state="unchanged"]')).toBeVisible(); + expect(publishedRequest).toMatchObject({ selectedSkillIds: ["sample-skill"], allowNewSkills: true, publishArtifacts: true }); +}); diff --git a/apps/web/src/api/skills.ts b/apps/web/src/api/skills.ts index 09cfff387..21270030d 100644 --- a/apps/web/src/api/skills.ts +++ b/apps/web/src/api/skills.ts @@ -51,6 +51,10 @@ import type { SkillSourceSyncApplyResponse, SkillSourceSyncCheckUpdatesResponse, SkillSourceSyncPreviewResponse, + SharedSkillSourceScanRequest, + SharedSkillSourceScanResponse, + SharedSkillSourcePublishRequest, + SharedSkillSourcePublishResponse, SkillSourceSyncPublishRequest, SkillSourceSyncPublishResponse, SkillSourceSyncRefreshResponse, @@ -115,6 +119,14 @@ export async function previewGstackSkillSourceSync(cwd: string, input: SkillSour return daemonClient.previewGstackSkillSourceSync(cwd, input); } +export async function previewSharedSkillSources(cwd: string, input: SharedSkillSourceScanRequest = {}): Promise { + return daemonClient.previewSharedSkillSources(cwd, input); +} + +export async function publishSharedSkillSources(cwd: string, input: SharedSkillSourcePublishRequest): Promise { + return daemonClient.publishSharedSkillSources(cwd, input); +} + export async function applyGstackSkillSourceSync(cwd: string): Promise { return daemonClient.applyGstackSkillSourceSync(cwd); } diff --git a/apps/web/src/features/settings/SharedSkillSourcesSection.tsx b/apps/web/src/features/settings/SharedSkillSourcesSection.tsx new file mode 100644 index 000000000..3452f14e2 --- /dev/null +++ b/apps/web/src/features/settings/SharedSkillSourcesSection.tsx @@ -0,0 +1,169 @@ +import { useState } from "react"; +import { FolderSearch, RefreshCw } from "lucide-react"; +import { + Button, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, EmptyState, InlineFeedback, Skeleton, +} from "../../shared/ui/index.js"; +import type { SkillSourceScanRoot, SkillSourceSyncPreviewRecord } from "../../types.js"; +import type { SettingsTranslator } from "./skill-library-utils.js"; +import { + canIgnoreSharedSkill, canPublishSharedSkill, sharedSkillSourceKey, sharedSkillSourceLocation, sharedSkillSourceRootLabel, sharedSkillSourceStatus, + visibleSharedSkillSourceRecords, +} from "./shared-skill-sources-model.js"; +import type { SharedSkillSourcesController } from "./useSharedSkillSourcesController.js"; + +export function SharedSkillSourcesSection({ controller, t }: { + readonly controller: SharedSkillSourcesController; + readonly t: SettingsTranslator; +}): React.JSX.Element { + const [showIgnored, setShowIgnored] = useState(false); + const [showOrphaned, setShowOrphaned] = useState(false); + const roots = controller.response?.preview.roots ?? []; + const visible = visibleSharedSkillSourceRecords(controller.records, controller.ignored, { showIgnored, showOrphaned }); + const ignoredCount = controller.records.filter((record) => controller.ignored.has(sharedSkillSourceKey(record))).length; + const orphanedCount = controller.records.filter((record) => record.state === "orphaned").length; + return ( +
+
+
+

{t("Local skill folders")}

+

{t("Discover skills in the project and user .agents/skills folders. Review each source before adding or updating it.")}

+
+ +
+ {controller.error ? {controller.error} : null} + {roots.length > 0 ? ( +
+ {roots.map((root, index) => {sharedSkillSourceRootLabel(root)})} +
+ ) : null} +
+ {t("{count} ready to review", { count: controller.actionableCount })} +
+ {ignoredCount > 0 ? ( + + ) : null} + {orphanedCount > 0 ? ( + + ) : null} + +
+
+ {controller.loading && !controller.response ? ( +
+ {[0, 1, 2].map((index) => )} +
+ ) : visible.length === 0 ? ( + } + title={controller.records.length === 0 ? t("No local skill folders found") : orphanedCount > 0 && !showOrphaned && ignoredCount === 0 ? t("Only orphaned sources remain") : t("All discovered skills are ignored")} + description={controller.records.length === 0 + ? t("Add a SKILL.md under the project or user .agents/skills folder, then scan again.") + : orphanedCount > 0 && !showOrphaned && ignoredCount === 0 + ? t("Show orphaned sources to inspect a snapshot without a matching active skill.") + : t("Show ignored skills to restore a candidate.")} /> + ) : ( +
+ {visible.map((record) => )} +
+ )} + { if (!open) controller.setConfirming(false); }}> + + + +

{t("Publish {count} selected skill sources to this workspace?", { count: controller.selectedRecords.length })}

+
    {controller.selectedRecords.map((record) =>
  • {record.frontMatter?.name ?? record.skillId} · {t(sharedSkillStatusKey(record.state))}
  • )}
+ {controller.hasNewSkills ? ( + + ) : null} +
+ + + + +
+
+
+ ); +} + +function SharedSkillSourceCard({ record, roots, controller, t }: { + readonly record: SkillSourceSyncPreviewRecord; + readonly roots: readonly SkillSourceScanRoot[]; + readonly controller: SharedSkillSourcesController; + readonly t: SettingsTranslator; +}): React.JSX.Element { + const key = sharedSkillSourceKey(record); + const ignored = controller.ignored.has(key); + const status = sharedSkillSourceStatus(record, controller.ignored); + const publishable = canPublishSharedSkill(record) && !ignored; + const root = roots.find((item) => item.id === record.sourceRootId); + const label = record.frontMatter?.name ?? record.definitionPreview?.name ?? record.skillId; + return ( +
+
+ {publishable ? controller.toggleSelected(record)} /> : null} +
+ {label} + {sharedSkillSourceRootLabel(root)} · {sharedSkillSourceLocation(record, roots)} +
+ {t(sharedSkillStatusKey(status))} +
+

{record.frontMatter?.description ?? record.definitionPreview?.summary ?? t("No description")}

+
+
+ {t("View source and changes")} + {record.diff.length > 0 ?
    {record.diff.map((item, index) =>
  • {item.label} ({item.severity})
  • )}
: null} + {record.semanticDiff.length > 0 ?
    {record.semanticDiff.map((item, index) =>
  • {item.severity}: {item.message}
  • )}
: null} + {record.contextFindings.length > 0 ?
    {record.contextFindings.map((item, index) =>
  • {item.message}
  • )}
: null} + {record.warnings.length > 0 ?
    {record.warnings.map((item, index) =>
  • {item.severity}: {item.message}
  • )}
: null} +
+

{t("Referenced files")}

+ {record.referencedFiles.length > 0 ? ( +
    + {record.referencedFiles.map((file) => ( +
  • + {file.path} · {file.kind} · {file.sizeBytes === undefined ? t("Size unknown") : t("{size} bytes", { size: file.sizeBytes })} · {file.checksum ?? t("Checksum unavailable")} · {file.includedInSnapshot ? t("Included") : t("Deferred")} +
  • + ))} +
+ ) :

{t("No referenced files")}

} +
+ {record.snapshotPreview?.rawSkillMarkdown ?
{record.snapshotPreview.rawSkillMarkdown}
:

{t("Source preview unavailable")}

} +
+ {publishable ? : null} + {(canIgnoreSharedSkill(record) || ignored) ? ( + + ) : null} +
+
+ ); +} + +function sharedSkillStatusKey(status: string): string { + switch (status) { + case "new": return "New skill"; + case "attachable": return "Ready to attach"; + case "drifted": return "Update available"; + case "unchanged": return "Up to date"; + case "missing": return "Source missing"; + case "blocked": return "Blocked"; + case "orphaned": return "Orphaned source"; + case "ignored": return "Ignored"; + default: return status; + } +} diff --git a/apps/web/src/features/settings/SkillLibraryNavigation.tsx b/apps/web/src/features/settings/SkillLibraryNavigation.tsx index ede4f6409..09041a880 100644 --- a/apps/web/src/features/settings/SkillLibraryNavigation.tsx +++ b/apps/web/src/features/settings/SkillLibraryNavigation.tsx @@ -4,12 +4,14 @@ import { SettingsSubnav } from "./SettingsSubnav.js"; export function SkillLibraryNavigation({ activeSection, currentUserCount, + localCount, evolutionCount, builtinCount, onSectionChange, }: { readonly activeSection: SkillLibrarySectionId; readonly currentUserCount: number; + readonly localCount: number | undefined; readonly evolutionCount: number; readonly builtinCount: number; readonly onSectionChange: (section: SkillLibrarySectionId) => void; @@ -25,6 +27,11 @@ export function SkillLibraryNavigation({ label: "My skills", badge: `${currentUserCount}`, }, + { + id: "local", + label: "Local skill folders", + ...(localCount !== undefined ? { badge: `${localCount}` } : {}), + }, { id: "evolution", label: "AI evolution", diff --git a/apps/web/src/features/settings/SkillLibrarySettingsPanel.tsx b/apps/web/src/features/settings/SkillLibrarySettingsPanel.tsx index 06ec1686a..edb5aa198 100644 --- a/apps/web/src/features/settings/SkillLibrarySettingsPanel.tsx +++ b/apps/web/src/features/settings/SkillLibrarySettingsPanel.tsx @@ -8,10 +8,12 @@ import { SkillEvolutionSection } from "./SkillEvolutionSection.js"; import { SkillImportDialog } from "./SkillImportDialog.js"; import { SkillLibraryNavigation } from "./SkillLibraryNavigation.js"; import { SkillLibraryRegistrySection } from "./SkillLibraryRegistrySection.js"; +import { SharedSkillSourcesSection } from "./SharedSkillSourcesSection.js"; import { useSkillEditorController } from "./useSkillEditorController.js"; import { useSkillImportController } from "./useSkillImportController.js"; import { useSkillLibraryActionsController } from "./useSkillLibraryActionsController.js"; import { useSkillLibraryDataController } from "./useSkillLibraryDataController.js"; +import { useSharedSkillSourcesController } from "./useSharedSkillSourcesController.js"; export function SkillLibrarySettingsPanel({ cwd, @@ -53,6 +55,15 @@ export function SkillLibrarySettingsPanel({ }, }); const actions = useSkillLibraryActionsController({ data, editor: skillEditor, showToast, t }); + const sharedSources = useSharedSkillSourcesController({ + cwd, + active: data.activeSubsection === "local", + refreshToken, + onPublished: async () => { + await data.refresh(); + showToast({ message: t("Selected skill sources published. Teammate bindings and runtime injection are unchanged."), scope: "skill:shared-source", tone: "success" }); + }, + }); const commonRegistryProps = { skills: data.skills, @@ -91,6 +102,7 @@ export function SkillLibrarySettingsPanel({ + ) : data.activeSubsection === "local" ? ( + ) : ( {t("Skill")} {t("Description")} + {t("Source")} {t("Bound teammates")} {t("Updated")} {variant !== "builtin" ? {t("Skill actions")} : null} @@ -70,7 +72,7 @@ export function SkillLibraryTable({ {entries.length === 0 ? ( - + @@ -103,6 +105,7 @@ export function SkillLibraryTable({ {entry.definition.summary || "-"} + {skillSourceLabel(entry.source, t)} {boundTeammates.length > 0 ? boundTeammates.join(", ") : t("Unbound")} {formatKnownDateTime(entry.updatedAt, entry.source === "builtin" ? t("Built-in") : t("Unknown"))} {variant !== "builtin" ? ( diff --git a/apps/web/src/features/settings/SkillSourceInspector.tsx b/apps/web/src/features/settings/SkillSourceInspector.tsx index 978b75391..f673972b1 100644 --- a/apps/web/src/features/settings/SkillSourceInspector.tsx +++ b/apps/web/src/features/settings/SkillSourceInspector.tsx @@ -70,6 +70,18 @@ export function SkillSourceInspector({ {t("Review")}: {snapshot.reviewStatus} {t("Artifacts")}: {artifacts.length} +
+

{t("Referenced files")}

+ {snapshot.referencedFiles.length > 0 ? ( +
    + {snapshot.referencedFiles.map((file) => ( +
  • + {file.path} · {file.kind} · {file.sizeBytes === undefined ? t("Size unknown") : t("{size} bytes", { size: file.sizeBytes })} · {file.checksum ?? t("Checksum unavailable")} · {file.includedInSnapshot ? t("Included") : t("Deferred")} +
  • + ))} +
+ ) :

{t("No referenced files")}

} +
{showPreviewAction ? (