Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,8 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.EnumSet;
import java.util.HashMap;
Expand All @@ -52,7 +51,7 @@
*
* <p>The stager keeps no cross-call state: each invocation rebuilds the white-list of
* directories that should remain under {@code .skills-cache}, materialises any files whose
* SHA-256 has changed, and deletes orphan directories not present in the white-list.
* bytes have changed, and deletes orphan directories not present in the white-list.
*
* <p>Workspace-native skills (those produced by {@link WorkspaceSkillRepository}) are NOT
* staged: they already live under {@code <wsRoot>/skills/} (or are produced lazily from the
Expand Down Expand Up @@ -200,11 +199,8 @@ private void materializeIfChanged(Path stagedDir, Map<String, String> resources)

private void writeIfChanged(Path target, byte[] bytes) throws IOException {
Files.createDirectories(target.getParent());
if (Files.exists(target)) {
byte[] existing = Files.readAllBytes(target);
if (sha256(existing).equals(sha256(bytes))) {
return;
}
if (contentUnchanged(target, bytes)) {
return;
}
Files.write(target, bytes);
// Heuristic exec-bit recovery: the ingestion path turns files into Strings and discards
Expand All @@ -213,6 +209,20 @@ private void writeIfChanged(Path target, byte[] bytes) throws IOException {
maybeMarkExecutable(target, bytes);
}

/**
* True when {@code target} already holds {@code bytes}. Length is checked first so a
* restage that cannot match does not read the whole file. Package-private for tests.
*/
static boolean contentUnchanged(Path target, byte[] bytes) throws IOException {
if (!Files.exists(target)) {
return false;
}
if (Files.size(target) != bytes.length) {
return false;
}
return Arrays.equals(Files.readAllBytes(target), bytes);
}

/**
* Script-detection heuristic: shebang at byte 0/1 OR a known-script suffix. Match → add
* owner-exec on POSIX filesystems. Non-POSIX (Windows) silently no-op.
Expand Down Expand Up @@ -348,21 +358,6 @@ private static byte[] decode(String content) {
return content.getBytes(StandardCharsets.UTF_8);
}

private static String sha256(byte[] bytes) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(bytes);
byte[] hash = md.digest();
StringBuilder sb = new StringBuilder(hash.length * 2);
for (byte b : hash) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
}

/**
* Resolves per-repository {@code source} namespaces. When two repositories report the same
* {@code getSource()}, the second and subsequent ones receive an {@code _<idx>} suffix
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/*
* Copyright 2024-2026 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.agentscope.harness.agent.skill.runtime;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import io.agentscope.core.skill.AgentSkill;
import io.agentscope.core.skill.repository.AgentSkillRepository;
import io.agentscope.core.skill.repository.AgentSkillRepositoryInfo;
import io.agentscope.harness.agent.skill.runtime.MarketplaceStager.RepoBound;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

/**
* {@link MarketplaceStager} used to hash the on-disk copy and the incoming bytes separately
* on every restage. Size mismatch must be enough to decide the file changed, and identical
* bytes must not rewrite the file (mtime stays put).
*/
class MarketplaceStagerWriteIfChangedTest {

@Test
@DisplayName("Missing file is treated as changed")
void missingFileIsChanged(@TempDir Path dir) throws Exception {
assertFalse(MarketplaceStager.contentUnchanged(dir.resolve("missing.bin"), new byte[] {1}));
}

@Test
@DisplayName("Different length skips a full-content compare")
void sizeMismatchIsChanged(@TempDir Path dir) throws Exception {
Path file = dir.resolve("a.bin");
Files.write(file, new byte[] {1, 2, 3});
assertFalse(MarketplaceStager.contentUnchanged(file, new byte[] {1, 2}));
}

@Test
@DisplayName("Identical payload is unchanged")
void identicalBytesUnchanged(@TempDir Path dir) throws Exception {
Path file = dir.resolve("a.bin");
byte[] payload = "hello-skill".getBytes(StandardCharsets.UTF_8);
Files.write(file, payload);
assertTrue(MarketplaceStager.contentUnchanged(file, payload));
}

@Test
@DisplayName("Same length but different bytes is changed")
void sameSizeDifferentBytesIsChanged(@TempDir Path dir) throws Exception {
Path file = dir.resolve("a.bin");
Files.write(file, new byte[] {1, 2, 3});
assertFalse(MarketplaceStager.contentUnchanged(file, new byte[] {1, 2, 9}));
}

@Test
@DisplayName("Restaging the same resource does not rewrite the file")
void restageOfIdenticalResourceDoesNotRewrite(@TempDir Path workspace) throws Exception {
AgentSkill skill =
new AgentSkill(
"demo",
"A demo skill.",
"# demo",
Map.of("SKILL.md", "# demo\nunchanged body\n"),
"marketplace");
StubRepo repo = new StubRepo(List.of(skill), "market");
MarketplaceStager stager = new MarketplaceStager(workspace);
List<RepoBound> visible = List.of(new RepoBound(skill, repo));
Map<AgentSkillRepository, String> ns = Map.of(repo, "market");

stager.stage(visible, ns);
Path staged = workspace.resolve(".skills-cache/market/demo/SKILL.md");
assertTrue(Files.exists(staged));
Files.setLastModifiedTime(staged, FileTime.from(Instant.parse("2020-01-01T00:00:00Z")));
FileTime before = Files.getLastModifiedTime(staged);

stager.stage(visible, ns);
assertEquals(before, Files.getLastModifiedTime(staged));
assertEquals("# demo\nunchanged body\n", Files.readString(staged));
}

@Test
@DisplayName("Restaging a different payload updates the file")
void restageOfChangedResourceRewrites(@TempDir Path workspace) throws Exception {
AgentSkill first =
new AgentSkill(
"demo", "A demo skill.", "# demo", Map.of("SKILL.md", "v1"), "marketplace");
StubRepo repo = new StubRepo(List.of(first), "market");
MarketplaceStager stager = new MarketplaceStager(workspace);
Map<AgentSkillRepository, String> ns = Map.of(repo, "market");

stager.stage(List.of(new RepoBound(first, repo)), ns);
Path staged = workspace.resolve(".skills-cache/market/demo/SKILL.md");
assertEquals("v1", Files.readString(staged));

AgentSkill second =
new AgentSkill(
"demo",
"A demo skill.",
"# demo",
Map.of("SKILL.md", "v2-longer"),
"marketplace");
stager.stage(List.of(new RepoBound(second, repo)), ns);
assertEquals("v2-longer", Files.readString(staged));
}

private static final class StubRepo implements AgentSkillRepository {
private final List<AgentSkill> skills;
private final String source;

StubRepo(List<AgentSkill> skills, String source) {
this.skills = skills;
this.source = source;
}

@Override
public AgentSkill getSkill(String name) {
return skills.stream().filter(s -> s.getName().equals(name)).findFirst().orElse(null);
}

@Override
public List<String> getAllSkillNames() {
return skills.stream().map(AgentSkill::getName).toList();
}

@Override
public List<AgentSkill> getAllSkills() {
return skills;
}

@Override
public boolean save(List<AgentSkill> skills, boolean force) {
return false;
}

@Override
public boolean delete(String skillName) {
return false;
}

@Override
public boolean skillExists(String skillName) {
return skills.stream().anyMatch(s -> s.getName().equals(skillName));
}

@Override
public AgentSkillRepositoryInfo getRepositoryInfo() {
return new AgentSkillRepositoryInfo(source, "", false);
}

@Override
public String getSource() {
return source;
}

@Override
public void setWriteable(boolean writeable) {}

@Override
public boolean isWriteable() {
return false;
}
}
}
Loading