| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299 |
- """Install two pinned, public Skills into a host-supplied Skills directory.
- No guessed host directories, credential discovery, account creation or business writes.
- The official VOC artifact is downloaded from npm and verified before extraction.
- """
- import argparse
- import hashlib
- import io
- import json
- import os
- from pathlib import Path, PurePosixPath
- import re
- import shutil
- import subprocess
- import sys
- import tarfile
- import tempfile
- import urllib.request
- class InstallError(Exception):
- pass
- def require(condition, code):
- if not condition:
- raise InstallError(code)
- def digest(raw):
- return hashlib.sha256(raw).hexdigest()
- def encode(value):
- return (json.dumps(value, ensure_ascii=False, indent=2) + '\n').encode('utf-8')
- def safe_name(name):
- parts = PurePosixPath(name).parts
- return (isinstance(name, str) and bool(name) and '\\' not in name and ':' not in name
- and not name.startswith('/') and all(p not in ('..', '.') for p in parts)
- and name == '/'.join(parts))
- def plain_path(path):
- path = Path(os.path.abspath(path))
- for part in [*reversed(path.parents), path]:
- require(not part.is_symlink(), 'SYMLINK_PATH')
- if part.exists():
- require(not hasattr(part, 'is_junction') or not part.is_junction(), 'JUNCTION_PATH')
- return path
- def inside(parent, child):
- return child != parent and child.is_relative_to(parent)
- def verify_package(package):
- package = plain_path(package)
- manifest = package / 'FILES.sha256'
- require(manifest.is_file(), 'PACKAGE_MANIFEST_MISSING')
- names = set()
- for line in manifest.read_text(encoding='utf-8').splitlines():
- match = re.fullmatch(r'([a-f0-9]{64}) (.+)', line)
- require(match is not None, 'INVALID_PACKAGE_MANIFEST')
- h, name = match.groups()
- require(safe_name(name) and name not in names, 'INVALID_PACKAGE_PATH')
- names.add(name)
- p = plain_path(package / name)
- require(p.is_file() and digest(p.read_bytes()) == h, 'PACKAGE_HASH_MISMATCH')
- meta = json.loads((package / 'release.json').read_bytes())
- require(meta['schema'] == 'ksf.workshop-onboarding.v1', 'INVALID_RELEASE')
- return meta
- def voc_archive_files(raw, expected):
- require(len(raw) == expected['bytes'] and digest(raw) == expected['sha256'], 'VOC_ARCHIVE_HASH_MISMATCH')
- result = {}
- with tarfile.open(fileobj=io.BytesIO(raw), mode='r:gz') as archive:
- for member in archive.getmembers():
- require(member.name.startswith('package/'), 'INVALID_VOC_ARCHIVE_PATH')
- name = member.name[len('package/'):]
- require(safe_name(name), 'INVALID_VOC_ARCHIVE_PATH')
- require(member.isfile(), 'NON_FILE_VOC_ARCHIVE_MEMBER')
- require(name not in result and name in expected['files'], 'UNEXPECTED_VOC_ARCHIVE_FILE')
- require(member.size <= 8_000_000, 'VOC_ARCHIVE_MEMBER_TOO_LARGE')
- raw_file = archive.extractfile(member).read()
- require(digest(raw_file) == expected['files'][name], 'VOC_FILE_HASH_MISMATCH')
- result[name] = raw_file
- require(set(result) == set(expected['files']), 'VOC_ARCHIVE_INCOMPLETE')
- return result
- def source_files(package, meta, archive=None):
- expected = meta['voc']
- if archive:
- raw = plain_path(archive).read_bytes()
- else:
- require(expected['url'] == 'https://registry.npmjs.org/@vocmarket/voc-skill/-/voc-skill-0.3.42.tgz', 'UNEXPECTED_VOC_SOURCE')
- request = urllib.request.Request(expected['url'], headers={'User-Agent': 'ksf-workshop-onboarding/1'})
- with urllib.request.urlopen(request, timeout=30) as response:
- require(response.status == 200, 'VOC_DOWNLOAD_FAILED')
- raw = response.read(expected['bytes'] + 1)
- voc = voc_archive_files(raw, expected)
- workshop = {}
- for name, h in meta['workshop']['files'].items():
- require(safe_name(name), 'INVALID_WORKSHOP_PATH')
- raw_file = plain_path(package / 'skills/ksf-workshop-runtime' / name).read_bytes()
- require(digest(raw_file) == h, 'WORKSHOP_FILE_HASH_MISMATCH')
- workshop[name] = raw_file
- return {'ksf-workshop-runtime': workshop, 'voc-intelligence': voc}
- def mcp_fragment(voc_dir):
- return {'mcpServers': {'voc': {'command': 'node',
- 'args': [str(voc_dir / 'mcp/src/server.js')], 'cwd': str(voc_dir),
- 'env': {'VOC_SKILL_NO_UPDATE_CHECK': '1'}}}}
- def verify_component(target, files):
- require(target.is_dir(), 'EXISTING_SKILL_NOT_DIRECTORY')
- for name, raw in files.items():
- p = plain_path(target / name)
- require(p.is_file() and digest(p.read_bytes()) == digest(raw), 'EXISTING_SKILL_DIFFERS')
- def plan(skills_dir, files):
- actions = []
- for name, content in files.items():
- target = plain_path(skills_dir / name)
- if target.exists():
- verify_component(target, content)
- action = 'verify-only'
- else:
- action = 'create'
- actions.append({'skill': name, 'path': str(target), 'action': action, 'files': len(content)})
- return actions
- def remove_created(path, intended_parent):
- """Only remove our own newly created directory, after checking its final location."""
- path = plain_path(path)
- intended_parent = plain_path(intended_parent)
- require(inside(intended_parent, path), 'CLEANUP_PATH_OUTSIDE_TARGET')
- require(path.name.startswith('.ksf-init-') or path.name in ('ksf-workshop-runtime', 'voc-intelligence'),
- 'CLEANUP_TARGET_NOT_OWNED')
- shutil.rmtree(path)
- def install_files(skills_dir, files, actions):
- skills_dir.mkdir(parents=True, exist_ok=True)
- staging = Path(tempfile.mkdtemp(prefix='.ksf-init-', dir=skills_dir))
- created = []
- try:
- for row in actions:
- if row['action'] != 'create':
- continue
- staged = staging / row['skill']
- for name, raw in files[row['skill']].items():
- p = staged / name
- p.parent.mkdir(parents=True, exist_ok=True)
- p.write_bytes(raw)
- verify_component(staged, files[row['skill']])
- # Recheck both targets before committing either component.
- for row in actions:
- target = skills_dir / row['skill']
- if row['action'] == 'create':
- require(not target.exists(), 'CONCURRENT_INSTALL_CHANGE')
- else:
- verify_component(target, files[row['skill']])
- for row in actions:
- if row['action'] == 'create':
- target = skills_dir / row['skill']
- require(not target.exists(), 'CONCURRENT_INSTALL_CHANGE')
- (staging / row['skill']).rename(target)
- created.append(target)
- for row in actions:
- verify_component(skills_dir / row['skill'], files[row['skill']])
- except Exception:
- for target in reversed(created):
- verify_component(target, files[target.name])
- remove_created(target, skills_dir)
- raise
- finally:
- if staging.exists():
- remove_created(staging, skills_dir)
- def node_and_npm():
- node = shutil.which('node')
- npm = shutil.which('npm')
- if not node or not npm:
- return None
- version = subprocess.run([node, '-p', 'process.versions.node'], capture_output=True, timeout=15, check=False)
- require(version.returncode == 0 and int(version.stdout.decode().strip().split('.')[0]) >= 18,
- 'NODE_18_REQUIRED')
- if os.name == 'nt':
- cli = Path(npm).parent / 'node_modules/npm/bin/npm-cli.js'
- require(cli.is_file(), 'NPM_CLI_NOT_FOUND')
- return [node, str(cli)]
- return [npm]
- def prepare_runtime(package, voc_dir, output_dir):
- npm = node_and_npm()
- if not npm:
- return {'status': 'pending', 'reason': 'NODE_OR_NPM_NOT_AVAILABLE'}
- # Dependency resolution affects this newly installed VOC directory only.
- command = npm + ['install', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund',
- '--registry', 'https://registry.npmjs.org', '--cache', str(output_dir / 'npm-cache')]
- result = subprocess.run(command, cwd=voc_dir, capture_output=True, check=False, timeout=240)
- # Raw process logs are intentionally not copied into the user-facing receipt.
- if result.returncode:
- return {'status': 'pending', 'reason': 'VOC_DEPENDENCY_INSTALL_FAILED', 'exitCode': result.returncode}
- check = subprocess.run([shutil.which('node'), str(package / 'check-voc.cjs'), str(voc_dir)],
- cwd=package, capture_output=True, check=False, timeout=40)
- if check.returncode:
- return {'status': 'pending', 'reason': 'VOC_LOCAL_CHECK_FAILED', 'exitCode': check.returncode}
- value = json.loads(check.stdout)
- require(value.get('ok') is True, 'INVALID_VOC_CHECK_RESULT')
- return {'status': 'verified', 'check': value}
- def run(args, package=None):
- package = Path(package).resolve() if package else Path(__file__).resolve().parent
- require(Path(args.skills_dir).is_absolute(), 'ABSOLUTE_HOST_SKILLS_DIRECTORY_REQUIRED')
- skills_dir = plain_path(args.skills_dir)
- require(skills_dir != Path(skills_dir.anchor), 'FILESYSTEM_ROOT_NOT_ALLOWED')
- require(not skills_dir.exists() or skills_dir.is_dir(), 'SKILLS_DIRECTORY_REQUIRED')
- require(not package.is_relative_to(skills_dir) and not skills_dir.is_relative_to(package), 'PACKAGE_TARGET_OVERLAP')
- require(not args.prepare_runtime or args.apply, 'PREPARE_RUNTIME_REQUIRES_APPLY')
- if args.apply:
- require(args.out and Path(args.out).is_absolute(), 'ABSOLUTE_RESULT_DIRECTORY_REQUIRED')
- output = plain_path(args.out)
- require(not output.exists(), 'RESULT_DIRECTORY_EXISTS')
- require(not output.is_relative_to(skills_dir) and not skills_dir.is_relative_to(output), 'RESULT_TARGET_OVERLAP')
- meta = verify_package(package)
- files = source_files(package, meta, args.voc_archive)
- # The npm artifact contains the author's machine-specific MCP paths.
- # Adapt that one generated configuration after verifying the original artifact.
- original_mcp_sha = digest(files['voc-intelligence']['.mcp.json'])
- files['voc-intelligence']['.mcp.json'] = encode(mcp_fragment(skills_dir / 'voc-intelligence'))
- actions = plan(skills_dir, files)
- receipt = {'schema': 'ksf.workshop-onboarding-result.v1', 'ok': True,
- 'releaseId': meta['releaseId'], 'readOnly': not args.apply, 'actions': actions,
- 'filesInstalled': False, 'hostSkillsLoaded': 'not-verified',
- 'vocRuntime': {'status': 'not-checked'}, 'hostToolRegistration': 'not-verified',
- 'vocLiveAccess': 'not-verified', 'researchLibraryAccess': 'not-verified',
- 'businessSaveAccess': 'not-verified', 'accountOrEnterpriseCreated': False,
- 'researchOrBusinessDataWritten': False, 'remoteInstalled': False,
- 'adaptedVocConfig': {'path': '.mcp.json', 'originalSha256': original_mcp_sha,
- 'installedSha256': digest(files['voc-intelligence']['.mcp.json'])},
- 'note': 'Files, local runtime, host discovery and live access have separate acceptance.'}
- if not args.apply:
- return receipt
- output.mkdir(parents=True, exist_ok=False)
- try:
- install_files(skills_dir, files, actions)
- receipt['filesInstalled'] = True
- voc_dir = skills_dir / 'voc-intelligence'
- # A neutral fragment is written to the result directory only; the host owns registration.
- (output / 'voc-mcp.fragment.json').write_bytes(encode(mcp_fragment(voc_dir)))
- receipt['mcpFragment'] = str(output / 'voc-mcp.fragment.json')
- if args.prepare_runtime:
- try:
- receipt['vocRuntime'] = prepare_runtime(package, voc_dir, output)
- except subprocess.TimeoutExpired:
- receipt['vocRuntime'] = {'status': 'pending', 'reason': 'RUNTIME_PREPARATION_TIMEOUT'}
- else:
- receipt['vocRuntime'] = {'status': 'pending', 'reason': 'DEPENDENCIES_NOT_PREPARED'}
- except Exception as exc:
- receipt['ok'] = False
- receipt['errorCode'] = str(exc) if isinstance(exc, InstallError) else 'INSTALL_FAILED'
- raise
- finally:
- (output / 'install-receipt.json').write_bytes(encode(receipt))
- return receipt
- def main():
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument('--skills-dir', required=True, help='Actual Skills directory provided by the host')
- parser.add_argument('--out', help='New absolute result directory outside the Skills directory')
- parser.add_argument('--voc-archive', help='Optional local copy of the exact official npm tarball')
- parser.add_argument('--apply', action='store_true')
- parser.add_argument('--prepare-runtime', action='store_true')
- args = parser.parse_args()
- try:
- receipt = run(args)
- print(json.dumps(receipt, ensure_ascii=False, indent=2))
- return 0
- except Exception as exc:
- code = str(exc) if isinstance(exc, InstallError) else 'INITIALIZATION_FAILED'
- print(json.dumps({'ok': False, 'errorCode': code, 'note': 'No credentials or raw environment were printed.'}))
- return 1
- if __name__ == '__main__':
- sys.exit(main())
|