Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/windows.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Windows

on:
pull_request:
push:
branches:
- main

permissions:
contents: read

jobs:
test:
name: Windows build and integration tests
runs-on: windows-latest
steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Configure Git test behavior
run: git config --global core.autocrlf false

- name: Install Rust
uses: dtolnay/rust-toolchain@stable

- name: Install Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.4.2

- name: Build native CLI
run: cargo build --locked

- name: Build test-hook CLI
run: cargo build --locked --features test-hooks --target-dir target/test-hooks

- name: Run Rust tests
run: cargo test --locked

- name: Install JavaScript test dependencies
run: bun install --frozen-lockfile

- name: Type-check JavaScript harness
run: bun x tsc --noEmit

- name: Run Windows integration tests
run: bun test
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,33 +49,33 @@ Subscribe records a stable source-plus-relative-path relationship key, so same-n
`restore --from PATH` is an explicit, noninteractive recovery operation. PATH must be one durable deletion recovery directory beneath the configured `recovery/` root and contain a strict `package/` snapshot; the manifest supplies the skill name unless matching `--skill NAME` is provided. It stages and validates the complete no-follow snapshot, preserves operational-looking files and Unix modes, and installs only when the canonical destination is absent. Existing differing content, malformed or linked content, path escapes, and source/destination overlap fail without overwrite. An identical validated destination returns `already_present`. Restore rehydrates canonical package content only: it never recreates subscriptions, publications, harness links, sets, or remote effects; use their explicit commands separately.


State loads strictly validate every local adoption record (identity, absolute no-follow source, canonical library destination, hash, and status); tampered records are rejected before status, doctor, or mutation. `SKILL.md` must begin with exactly one unquoted `name: <safe-component>` line; prose or duplicate/conflicting names are rejected. Imports stage content and roll back the newly installed package if provenance state cannot be persisted. Skill names and source-relative package paths reject absolute paths, parent traversal, separators where a package key is required, and control characters. Symlinks are rejected; regular-file scans use no-follow opens on Unix, and live package/publication replacements are staged and swapped through an anchored directory rename. Managed snapshots are rechecked before commit or replacement and abort when a concurrent change is detected; import installation uses an anchored no-replace primitive (Linux `renameat2`, Windows native no-overwrite move) and fails closed where unavailable. External writers are not fully serialized. Bundled files are never executed. `SKILLSYNC_TEST_FAIL_STATE_SAVE=1` is a test-only failure-injection hook for exercising rollback; it is not a configuration or recovery bypass.
State loads strictly validate every local adoption record (identity, absolute no-follow source, canonical library destination, hash, and status); tampered records are rejected before status, doctor, or mutation. `SKILL.md` must begin with exactly one unquoted `name: <safe-component>` line; prose or duplicate/conflicting names are rejected. Imports stage content and roll back the newly installed package if provenance state cannot be persisted. Skill names and source-relative package paths reject absolute paths, parent traversal, separators where a package key is required, and control characters. Symlinks and Windows reparse points are rejected; regular-file scans use no-follow opens on Unix and handle-bound reparse checks on Windows, and live package/publication replacements are staged through identity-checked native directory moves. Managed snapshots are rechecked before commit or replacement and abort when a concurrent change is detected; import installation uses an anchored no-replace primitive (Linux `renameat2`, Windows native no-overwrite move) and fails closed where unavailable. External writers are not fully serialized. Bundled files are never executed. `SKILLSYNC_TEST_FAIL_STATE_SAVE=1` is a test-only failure-injection hook for exercising rollback; it is not a configuration or recovery bypass.

Package copies exclude operational files such as `.env`, logs, credential directories, and private keys. Publication updates managed files in `skills/<name>` without deleting the destination package wholesale, preserving unrelated operational files. Destination changes that cannot be attributed to the recorded publication are rejected. Publication records are written only after a meaningful commit and successful push; a durable pending-publication intent is written before each push so a failed push or post-push state write can be retried without losing the relationship. Mutating commands share a persistent advisory lock in the config directory; the lock is held for a whole operation or worker run, and status probes the lock rather than trusting a stale PID.

Updates compare the durable baseline, live package, and fetched upstream package. `conflicts list` inventories paused conflicts, and `conflicts show RELATIONSHIP` is inspection-only: it validates and displays an immutable manifest with base/local/incoming hashes and evidence paths. Manual side selection, resolve, resume, and isolated workspace editing remain explicitly deferred; no semantic or automatic conflict resolution is performed. Deletion recovery remains separate via `restore`.

`config edit` is supported only from an interactive terminal. It creates a uniquely named no-follow temporary file inside the config directory, invokes `VISUAL` before `EDITOR` as an executable with that temporary path as its sole argument, then validates and publishes the result without replacing a config created concurrently. Existing configs are published only when their identity and bytes remain unchanged; symlink/reparse substitutions and editor-replaced temporary files fail closed. It inherits terminal streams and reports editor failures. Native Windows publication deliberately fails closed and is unavailable in this implementation; native Windows runtime is not exercised here. It never parses editor command strings or runs through a shell; `--json` and redirected stdin/stdout fail instead of launching an editor.
`config edit` is supported only from an interactive terminal. It creates a uniquely named no-follow temporary file inside the config directory, invokes `VISUAL` before `EDITOR` as an executable with that temporary path as its sole argument, then validates and publishes the result without replacing a config created concurrently. Existing configs are published only when their identity and bytes remain unchanged; symlink/reparse substitutions and editor-replaced temporary files fail closed. It inherits terminal streams and reports editor failures. Unix publishes through directory handles; Windows publishes through reparse-protected file handles and verifies the directory/file identity before and after publication. It never parses editor command strings or runs through a shell; `--json` and redirected stdin/stdout fail instead of launching an editor.

## Testing the native binary, then run the TypeScript integration suite with Bun:

```sh
cargo build
bun install
bun test
bunx tsc --noEmit
bun x tsc --noEmit
```

The Bun suite uses isolated temporary Git repositories and configuration roots. It covers nested selection, default-branch checkout, local/upstream merge preservation, conflict recovery, scoped publication, operational-file retention, multiple publication destinations, unsubscribe/unpublish retention, foreground worker locking, JSON failures, and tampered state paths.

## Honest limits

The explicit harness integration is deliberately narrow: `harness link` creates one native directory symlink from a caller-supplied existing harness skill root to one canonical library skill, and edits through that link write back to the canonical package. `harness unlink` removes only a recorded link whose target still resolves to the expected canonical skill; canonical content is retained. Existing unrelated harness files are preserved and collisions, symlink/reparse roots, and unsafe names are rejected. `harness list`, `status`, and `doctor` report these relationships. Skillsync does not currently discover harnesses automatically, filter harness skills, reload running harnesses, curate Hermes autonomous learning, or promise universal harness compatibility; there is no silent copy fallback. Windows uses native directory symlinks and reports privilege/API failures. Windows directory-link and import runtime behavior is compile-checked in this environment but not runtime exercised; native Windows verification is not claimed.
The explicit harness integration is deliberately narrow: `harness link` creates one native directory symlink from a caller-supplied existing harness skill root to one canonical library skill, and edits through that link write back to the canonical package. `harness unlink` removes only a recorded link whose target still resolves to the expected canonical skill; canonical content is retained. Existing unrelated harness files are preserved and collisions, symlink/reparse roots, and unsafe names are rejected. `harness list`, `status`, and `doctor` report these relationships. Skillsync does not currently discover harnesses automatically, filter harness skills, reload running harnesses, curate Hermes autonomous learning, or promise universal harness compatibility; there is no silent copy fallback. Windows uses native directory symlinks and reports privilege/API failures. Windows build, CLI integration, import, replacement, recovery, publication, locking, and launcher paths run in the hosted `windows-latest` workflow; interactive editor behavior still requires a real terminal session.

The foreground worker is supported for `worker --once` and `worker --interval SECONDS`; it owns the same advisory state lock as mutating CLI commands. Named local sets are supported for organizing canonical library skills (`set create/list/show/add/remove`); membership is portable state only. Set publication, set subscription metadata, personal-library sync, and membership-change propagation remain unsupported. The TUI is intentionally read-only: it has no onboarding, mutation actions, worker registration, or semantic conflict resolution, and its capability display is not a claim that those operations are available. Registry integration, Hermes autonomous curation, and automatic harness discovery/filtering/reload remain unsupported. Semantic conflict resolution, source-specific authentication, and cross-device worker coordination remain unsupported. `unsubscribe` and `unpublish` remove only the relationship and retain installed/published content. `delete NAME --yes` is the only canonical-library deletion surface: it fails closed for active subscriptions, publications, harness links, or set membership, stages a complete recoverable package snapshot under `recovery/`, then removes only that package. Safe no-replace directory quarantine is verified on Linux (`renameat2`) and Windows (native no-overwrite move); on other Unix platforms deletion fails closed because this runtime has no portable atomic no-replace directory primitive. Set membership is intentionally not auto-removed; remove the skill from every set first. Harness set enablement is an explicit one-time expansion: it records the set name, root, and member identities, creates only native per-skill directory links, and later set membership changes are not propagated. `harness disable` removes only those recorded links after target validation and retains canonical content. Empty named sets are valid no-op enablements: they persist and reload as healthy relationships, repeated enablement is `already_enabled`, and disable removes only the relationship without filesystem mutation. Automatic harness discovery/filtering/reload remains unsupported. Repeated or missing deletion is reported as `already_absent` and never affects unrelated paths.

## Private npm launcher plumbing

`npm/` contains a private, development-only npm launcher package. Its `skillsync` bin forwards arguments and standard streams to a bundled platform/architecture native binary, or to the explicit `SKILLSYNC_NATIVE_BIN` override used by tests and deployments. It never searches `PATH` or an npx cache, downloads artifacts, installs a worker, or registers startup. Missing artifacts fail clearly.

This is launcher plumbing only, not a published `skillsync` npm distribution. Package ownership, release publication, and the precompiled native artifact matrix remain unresolved; no native artifacts are bundled in this repository. Durable installation and sign-in startup registration remain unsupported.
This is launcher plumbing only, not a published `skillsync` npm distribution. Package ownership, release publication, and the precompiled native artifact matrix remain unresolved; no native artifacts are bundled in this repository. Durable installation and sign-in startup registration remain unsupported.
84 changes: 68 additions & 16 deletions src/config_editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,10 @@ pub(crate) fn edit_config(a: &super::App, json: bool) -> Result<serde_json::Valu
}
let path = a.config.join("config.toml");
super::assert_no_symlink_path(&a.config, Path::new("."))?;
#[cfg(not(windows))]
let config_identity = fs::metadata(&a.config)?;
#[cfg(windows)]
let config_directory_identity = super::filesystem::windows_path_identity(&a.config)?;
#[cfg(unix)]
let directory = {
use std::os::fd::FromRawFd;
Expand Down Expand Up @@ -275,20 +278,74 @@ pub(crate) fn edit_config(a: &super::App, json: bool) -> Result<serde_json::Valu
.map_err(|error| anyhow!("config publication target changed: {error}"))?;
}
}
#[cfg(not(unix))]
#[cfg(windows)]
{
let current_directory = fs::metadata(&a.config)?;
if current_directory.len() != config_identity.len()
|| current_directory.modified().ok() != config_identity.modified().ok()
{
if super::filesystem::windows_path_identity(&a.config)? != config_directory_identity {
return Err(anyhow!(
"config directory changed while it was being edited"
));
}
if original.is_some() {
verify_original_unchanged(&path, original.as_ref().unwrap())?;
if let Some((original_metadata, original_bytes)) = &original {
verify_original_unchanged(
&path,
&(original_metadata.clone(), original_bytes.clone()),
)?;
let mut target = super::filesystem::open_regular_file_bound(&path, true, false)?;
let mut current = Vec::new();
target.read_to_end(&mut current)?;
if current != *original_bytes {
return Err(anyhow!("config changed while it was being edited"));
}
let publish_result = (|| -> Result<()> {
target.set_len(0)?;
target.rewind()?;
#[cfg(feature = "test-hooks")]
if std::env::var_os("SKILLSYNC_TEST_CONFIG_PUBLISH_FAILURE").is_some() {
std::env::remove_var("SKILLSYNC_TEST_CONFIG_PUBLISH_FAILURE");
return Err(anyhow!("injected config publication failure"));
}
target.write_all(&edited)?;
target.sync_all()?;
target.rewind()?;
let mut verified = Vec::new();
target.read_to_end(&mut verified)?;
if verified != edited {
return Err(anyhow!("published config bytes do not match the editor result"));
}
Ok(())
})();
if let Err(error) = publish_result {
if let Err(recovery) = restore_target(&mut target, original_bytes) {
return Err(anyhow!("recovery required: {error}; restoring original config failed: {recovery}"));
}
return Err(error);
}
verify_original_unchanged(&path, &(original_metadata.clone(), edited.clone()))
.map_err(|error| anyhow!("config publication target changed: {error}"))?;
} else {
let mut target = super::filesystem::open_regular_file_bound(&path, true, true)?;
let publication = (|| -> Result<()> {
target.write_all(&edited)?;
target.sync_all()?;
target.rewind()?;
let mut verified = Vec::new();
target.read_to_end(&mut verified)?;
if verified != edited {
return Err(anyhow!("published config bytes do not match the editor result"));
}
Ok(())
})();
if let Err(error) = publication {
drop(target);
let _ = fs::remove_file(&path);
return Err(error);
}
}
return Err(anyhow!("native Windows config publication is unavailable: no safe handle-anchored replacement primitive is guaranteed"));
}
#[cfg(not(any(unix, windows)))]
{
let _ = config_identity;
return Err(anyhow!("native config publication is unavailable on this platform"));
}
#[cfg(unix)]
if original.is_none() {
Expand Down Expand Up @@ -343,13 +400,8 @@ fn verify_original_unchanged(path: &Path, original: &(fs::Metadata, Vec<u8>)) ->
if !current_metadata.is_file() || current_metadata.file_type().is_symlink() {
return Err(anyhow!("config path changed while it was being edited"));
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if current_metadata.dev() != original.0.dev() || current_metadata.ino() != original.0.ino()
{
return Err(anyhow!("config path changed while it was being edited"));
}
if !same_object(&current_metadata, &original.0) {
return Err(anyhow!("config path changed while it was being edited"));
}
let current = super::read_regular_file(path, Some(&original.0))?;
if current != original.1 {
Expand All @@ -358,7 +410,7 @@ fn verify_original_unchanged(path: &Path, original: &(fs::Metadata, Vec<u8>)) ->
Ok(())
}

#[cfg(unix)]
#[cfg(any(unix, windows))]
fn restore_target(target: &mut fs::File, original: &[u8]) -> Result<()> {
target.set_len(0)?;
target.rewind()?;
Expand Down
Loading
Loading