Skip to content

Commit 77ebd3c

Browse files
fix(server): skills library never deletes or moves what it cannot prove is its own
Review findings on the skills library, each with a test that failed before: - Staging paths (.<name>.t3-discard, .<name>.t3-restore) were deleted recursively when present. Now one is removed only when sameContent shows it is a leftover (the shared copy for a discard, the real folder for a restore); otherwise the skill is reported as a conflict and skipped. - A skill folder with a link resolving outside itself is reported as an outsideLink conflict instead of moved, and sameContent treats equal link text that lands on different files as different. - An interrupted detach is finished at server start while the switch is off: only staged restores are renamed into place or dropped. - Copies are grouped by case-folded name, so Review and review conflict. - Only a link to <library>/<same name> counts as a library link. - sameContent compares executable bits. - Permission tests cover server.refreshSkillsLibrary and the status read. - t3_environment_preferences_update and t3_environment_read carry skillsLibraryEnabled (+42 bytes in the MCP tool list budget). Implemented by Claude Opus 5.5 via T3 Code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent c6ada14 commit 77ebd3c

13 files changed

Lines changed: 400 additions & 97 deletions

File tree

‎apps/server/src/auth/RpcAuthorization.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,15 @@ describe("RPC authorization scopes", () => {
7878
}
7979
});
8080

81+
it("lets read-only clients see skills library status but not move skill folders", () => {
82+
expect(requiredScopeForRpcMethod(WS_METHODS.serverGetSkillsLibraryStatus)).toBe(
83+
AuthOrchestrationReadScope,
84+
);
85+
expect(requiredScopeForRpcMethod(WS_METHODS.serverRefreshSkillsLibrary)).toBe(
86+
AuthSettingsWriteScope,
87+
);
88+
});
89+
8190
it("treats the project Loom as a thread read", () => {
8291
expect(requiredScopeForRpcMethod(WS_METHODS.projectReadLoomWindow)).toBe(
8392
AuthOrchestrationReadScope,

‎apps/server/src/mcp/toolkits/core.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import * as McpToolAccessTestkit from "../McpToolAccess.testkit.ts";
5858
import * as AnalyticsService from "../../telemetry/AnalyticsService.ts";
5959
import { dispatchFailure } from "../threadAccess.ts";
6060
import * as EnvironmentHandlers from "./environment/handlers.ts";
61+
import { EnvironmentToolkit } from "./environment/tools.ts";
6162
import * as AttachmentHandlers from "./attachment/handlers.ts";
6263
import * as PeerForwarding from "../../peer/PeerForwarding.ts";
6364
import * as PeerLinkRequests from "../../peer/PeerLinkRequests.ts";
@@ -490,6 +491,7 @@ it.effect("keeps unexpected handler defects private through the production regis
490491
it("keeps MCP preference output allowlisted and Unicode-bounded", () => {
491492
const settings = {
492493
...DEFAULT_SERVER_SETTINGS,
494+
skillsLibraryEnabled: true,
493495
privateCredential: "must-not-escape",
494496
sourceControlWritingStyle: {
495497
...DEFAULT_SERVER_SETTINGS.sourceControlWritingStyle,
@@ -499,10 +501,15 @@ it("keeps MCP preference output allowlisted and Unicode-bounded", () => {
499501
const result = EnvironmentHandlers.preferences(settings);
500502
expect(result).not.toHaveProperty("privateCredential");
501503
expect(result).not.toHaveProperty("providers");
504+
expect(result.skillsLibraryEnabled).toBe(true);
502505
expect(result.sourceControlWritingStyle).toMatchObject({
503506
customInstructions: "🙂".repeat(4000),
504507
truncated: true,
505508
});
509+
const update = Schema.decodeUnknownSync(
510+
EnvironmentToolkit.tools.t3_environment_preferences_update.parametersSchema,
511+
);
512+
expect(update({ skillsLibraryEnabled: true })).toMatchObject({ skillsLibraryEnabled: true });
506513
});
507514

508515
it.effect("resolves reused attachment references from stored metadata", () =>

‎apps/server/src/mcp/toolkits/environment/handlers.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export function preferences(settings: ServerSettings) {
1313
defaultThreadEnvMode,
1414
newWorktreesStartFromOrigin,
1515
enableProviderUpdateChecks,
16+
skillsLibraryEnabled,
1617
backgroundActivity,
1718
sourceControlWritingStyle,
1819
} = settings;
@@ -21,6 +22,7 @@ export function preferences(settings: ServerSettings) {
2122
defaultThreadEnvMode,
2223
newWorktreesStartFromOrigin,
2324
enableProviderUpdateChecks,
25+
skillsLibraryEnabled,
2426
backgroundActivity: { profile: backgroundActivity.profile },
2527
sourceControlWritingStyle: {
2628
...sourceControlWritingStyle,

‎apps/server/src/mcp/toolkits/environment/tools.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const PreferenceFields = {
1919
defaultThreadEnvMode: ServerSettings.fields.defaultThreadEnvMode,
2020
newWorktreesStartFromOrigin: ServerSettings.fields.newWorktreesStartFromOrigin,
2121
enableProviderUpdateChecks: ServerSettings.fields.enableProviderUpdateChecks,
22+
skillsLibraryEnabled: ServerSettings.fields.skillsLibraryEnabled,
2223
backgroundActivity: Schema.Struct({ profile: BackgroundActivityProfileSelection }),
2324
sourceControlWritingStyle: Schema.Struct({
2425
mode: Schema.String,
@@ -62,6 +63,7 @@ const EnvironmentPreferencesTool = Tool.make("t3_environment_preferences_update"
6263
defaultThreadEnvMode: ServerSettingsPatch.fields.defaultThreadEnvMode,
6364
newWorktreesStartFromOrigin: ServerSettingsPatch.fields.newWorktreesStartFromOrigin,
6465
enableProviderUpdateChecks: ServerSettingsPatch.fields.enableProviderUpdateChecks,
66+
skillsLibraryEnabled: ServerSettingsPatch.fields.skillsLibraryEnabled,
6567
backgroundActivity: Schema.optionalKey(Schema.Struct({ profile: BackgroundActivityProfile })),
6668
sourceControlWritingStyle: ServerSettingsPatch.fields.sourceControlWritingStyle,
6769
}),
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
22
"claudeSystemAppendBytes": 7975,
33
"codexAdditionalContextBytes": 9701,
4-
"mcpToolListJsonBytes": 60654
4+
"mcpToolListJsonBytes": 60696
55
}

‎apps/server/src/skills/SkillsLibrary.test.ts‎

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import * as HostProcess from "@t3tools/shared/HostProcess";
66
import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
77
import * as Effect from "effect/Effect";
88
import * as FileSystem from "effect/FileSystem";
9+
import * as Layer from "effect/Layer";
910
import * as Path from "effect/Path";
11+
import * as PubSub from "effect/PubSub";
1012

13+
import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
1114
import * as ServerSettings from "../serverSettings.ts";
1215
import * as SkillsLibrary from "./SkillsLibrary.ts";
1316

@@ -111,19 +114,52 @@ it.layer(NodeServices.layer)("SkillsLibrary", (it) => {
111114
"moves neither copy of a skill whose copies differ and reports both paths",
112115
() =>
113116
Effect.gen(function* () {
114-
const { path, claude, codex, library, service, skill, read, link } = yield* setup();
117+
const { fs, path, home, claude, codex, library, service, skill, read, link } =
118+
yield* setup();
115119
yield* skill(claude, "review", "claude version");
116120
yield* skill(codex, "review", "codex version");
117121
yield* skill(codex, "other", "other");
122+
// One folder on a case-insensitive disk, two on a case-sensitive one.
123+
yield* skill(claude, "Deploy", "same");
124+
yield* skill(codex, "deploy", "same");
125+
// Moving it would break a link that climbs out of the skill.
126+
yield* fs.makeDirectory(path.join(claude, "linked-out"));
127+
yield* fs.writeFileString(path.join(home, ".claude", "shared.md"), "shared");
128+
yield* fs.symlink("../../shared.md", path.join(claude, "linked-out", "SKILL.md"));
129+
// Codex's equal copy would be parked at a path the user already uses.
130+
yield* skill(claude, "dup", "same");
131+
yield* skill(codex, "dup", "same");
132+
yield* skill(codex, ".dup.t3-discard", "mine");
118133

119134
yield* service.apply;
120135

121136
expect(yield* read(claude, "review")).toContain("claude version");
122137
expect(yield* read(codex, "review")).toContain("codex version");
123138
expect(yield* link(claude, "review")).toBeUndefined();
124139
expect(yield* link(claude, "other")).toBe(path.join(library, "other"));
140+
for (const [root, name] of [
141+
[claude, "Deploy"],
142+
[codex, "deploy"],
143+
[claude, "linked-out"],
144+
[claude, "dup"],
145+
[codex, "dup"],
146+
] as const) {
147+
expect(yield* link(root, name)).toBeUndefined();
148+
}
149+
expect(yield* read(claude, "linked-out")).toBe("shared");
150+
expect(yield* read(codex, ".dup.t3-discard")).toContain("mine");
125151
expect((yield* service.status).conflicts).toEqual([
152+
{ name: "Deploy", paths: [path.join(claude, "Deploy"), path.join(codex, "deploy")] },
126153
{ name: "review", paths: [path.join(claude, "review"), path.join(codex, "review")] },
154+
{
155+
name: "linked-out",
156+
paths: [path.join(claude, "linked-out"), path.join(claude, "linked-out", "SKILL.md")],
157+
reason: "outsideLink",
158+
},
159+
{
160+
name: "dup",
161+
paths: [path.join(codex, ".dup.t3-discard"), path.join(claude, "dup")],
162+
},
127163
]);
128164
}),
129165
);
@@ -151,11 +187,16 @@ it.layer(NodeServices.layer)("SkillsLibrary", (it) => {
151187
const { fs, path, home, claude, codex, library, service, skill, read, link } =
152188
yield* setup();
153189
yield* skill(claude, "hello", "hello");
190+
yield* skill(claude, "kept", "kept");
154191
yield* skill(codex, "codex-only", "codex");
155192
yield* service.apply;
156193
const elsewhere = path.join(home, "dev", "skills");
157194
yield* skill(elsewhere, "mine", "mine");
158195
yield* fs.symlink(path.join(elsewhere, "mine"), path.join(claude, "mine"));
196+
// Points into the library under another name, so it is the user's link.
197+
yield* fs.symlink(path.join(library, "hello"), path.join(claude, "alias"));
198+
// A folder at the staging path that is not a copy of what it would restore.
199+
yield* skill(claude, ".kept.t3-restore", "mine");
159200
// A crash after unlinking, before the rename, leaves only the staged copy.
160201
yield* fs.remove(path.join(claude, "codex-only"));
161202
yield* fs.copy(
@@ -171,11 +212,62 @@ it.layer(NodeServices.layer)("SkillsLibrary", (it) => {
171212
expect(yield* read(claude, name)).toBe(yield* read(library, name));
172213
}
173214
expect(yield* link(claude, "mine")).toBe(path.join(elsewhere, "mine"));
174-
expect((yield* fs.readDirectory(claude)).sort()).toEqual(["codex-only", "hello", "mine"]);
215+
expect(yield* link(claude, "alias")).toBe(path.join(library, "hello"));
216+
expect(yield* link(claude, "kept")).toBe(path.join(library, "kept"));
217+
expect(yield* read(claude, ".kept.t3-restore")).toContain("mine");
218+
expect((yield* fs.readDirectory(claude)).sort()).toEqual([
219+
".kept.t3-restore",
220+
"alias",
221+
"codex-only",
222+
"hello",
223+
"kept",
224+
"mine",
225+
]);
175226
// Codex reads the library itself, so it gets no second copy.
176227
expect(yield* fs.readDirectory(codex)).toEqual([]);
177-
expect((yield* fs.readDirectory(library)).sort()).toEqual(["codex-only", "hello"]);
178-
expect((yield* service.status).linked).toEqual([]);
228+
expect((yield* fs.readDirectory(library)).sort()).toEqual([
229+
"codex-only",
230+
"hello",
231+
"kept",
232+
]);
233+
const status = yield* service.status;
234+
expect(status.linked).toEqual([{ driver: "claudeAgent", name: "kept" }]);
235+
expect(status.conflicts).toEqual([
236+
{
237+
name: "kept",
238+
paths: [path.join(claude, ".kept.t3-restore"), path.join(claude, "kept")],
239+
},
240+
]);
241+
}),
242+
);
243+
244+
it.effect.skipIf(!symlinksSupported)(
245+
"finishes at startup a detach a restart interrupted while the switch is off",
246+
() =>
247+
Effect.gen(function* () {
248+
const { fs, path, home, claude, library, service, skill, read, link } = yield* setup();
249+
yield* skill(claude, "hello", "hello");
250+
yield* service.apply;
251+
yield* fs.remove(path.join(claude, "hello"));
252+
yield* fs.copy(path.join(library, "hello"), path.join(claude, ".hello.t3-restore"));
253+
254+
yield* Layer.build(SkillsLibrary.layer).pipe(
255+
Effect.provide(
256+
Layer.mergeAll(
257+
ServerSettings.ServerSettingsService.layerTest(),
258+
Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({
259+
listInstances: Effect.succeed([]),
260+
subscribeChanges: PubSub.unbounded<void>().pipe(Effect.flatMap(PubSub.subscribe)),
261+
}),
262+
),
263+
),
264+
Effect.provideService(HostProcess.HomeDirectory, home),
265+
Effect.provideService(HostProcess.Environment, { HOME: home }),
266+
);
267+
268+
expect(yield* link(claude, "hello")).toBeUndefined();
269+
expect(yield* read(claude, "hello")).toBe(yield* read(library, "hello"));
270+
expect(yield* fs.readDirectory(claude)).toEqual(["hello"]);
179271
}),
180272
);
181273
});

0 commit comments

Comments
 (0)