Skip to content

Commit 593ec1f

Browse files
fix(server): harden linking an existing voice model
Stage the link and rename it into place, record the verified marker from the file that was hashed, remember where a hard link came from, and require filesystem read access to name a path. Co-Authored-By: Claude Fable 5.2 <[email protected]>
1 parent 425f81a commit 593ec1f

4 files changed

Lines changed: 47 additions & 8 deletions

File tree

‎apps/server/src/speech/SpeechModelStore.ts‎

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -254,13 +254,30 @@ const make = Effect.gen(function* () {
254254
// Pointing at T3 Code's own model folder must not replace a file with a link to itself.
255255
if ((yield* realPath(file)) === (yield* realPath(finalPath))) continue;
256256
if (yield* verifiedPath(model, finalPath)) continue;
257-
if (!(yield* digestMatches(model, file))) continue;
258-
yield* fs.remove(finalPath, { force: true });
257+
const hashed = yield* expectedFile(model, file);
258+
if (!hashed || !(yield* digestMatches(model, file))) continue;
259+
// Staged then renamed, so a failed link never removes the file already installed.
260+
const staged = `${finalPath}.${yield* random.uuidV4()}.part`;
259261
// Windows grants symlinks only to privileged accounts; a hard link also shares the bytes.
260-
yield* fs.symlink(file, finalPath).pipe(Effect.catch(() => fs.link(file, finalPath)));
261-
const info = yield* fs.stat(finalPath);
262+
const hardLinked = yield* fs.symlink(file, staged).pipe(
263+
Effect.as(false),
264+
Effect.catch(() => fs.link(file, staged).pipe(Effect.as(true))),
265+
);
266+
const current = yield* expectedFile(model, staged);
267+
const marker = identity(model, hashed);
268+
if (!current || identity(model, current) !== marker) {
269+
yield* fs.remove(staged, { force: true });
270+
continue;
271+
}
272+
yield* fs.rename(staged, finalPath);
273+
// A hard link cannot be read back, so its origin is recorded beside it.
274+
yield* (
275+
hardLinked
276+
? fs.writeFileString(`${finalPath}.source`, file, { mode: 0o600 })
277+
: fs.remove(`${finalPath}.source`, { force: true })
278+
).pipe(Effect.ignore);
262279
yield* fs
263-
.writeFileString(`${finalPath}.verified`, identity(model, info), { mode: 0o600 })
280+
.writeFileString(`${finalPath}.verified`, marker, { mode: 0o600 })
264281
.pipe(Effect.ignore);
265282
linked.push(model.id);
266283
}
@@ -305,6 +322,7 @@ const make = Effect.gen(function* () {
305322
if (!complete || !(yield* digestMatches(model, partialPath)))
306323
return yield* new SpeechModelDownloadError({ modelId: model.id, reason: "verification" });
307324
yield* fs.rename(partialPath, finalPath);
325+
yield* fs.remove(`${finalPath}.source`, { force: true }).pipe(Effect.ignore);
308326
// Rename changes file metadata, so record the verified file's final identity.
309327
const info = yield* fs.stat(finalPath);
310328
yield* fs
@@ -324,7 +342,10 @@ const make = Effect.gen(function* () {
324342
return SpeechModelStore.of({
325343
link,
326344
linkedFrom: Effect.fn("SpeechModelStore.linkedFrom")((model) =>
327-
fs.readLink(modelPath(model)).pipe(Effect.orElseSucceed(() => undefined)),
345+
fs.readLink(modelPath(model)).pipe(
346+
Effect.catch(() => fs.readFileString(`${modelPath(model)}.source`)),
347+
Effect.orElseSucceed(() => undefined),
348+
),
328349
),
329350
isReady: Effect.fn("SpeechModelStore.isReady")((model) =>
330351
expectedFile(model, modelPath(model)).pipe(
@@ -341,6 +362,7 @@ const make = Effect.gen(function* () {
341362
const file = modelPath(model);
342363
yield* fs.remove(file, { force: true });
343364
yield* fs.remove(`${file}.verified`, { force: true });
365+
yield* fs.remove(`${file}.source`, { force: true });
344366
}),
345367
});
346368
});

‎apps/server/src/speech/http.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,18 @@ effectIt.effect(
229229
422,
230230
);
231231
expect(transcribe).toHaveBeenLastCalledWith("openrouter", "acme/transcribe-1");
232+
// Naming a path on the environment needs filesystem access, not only settings access.
233+
const link = yield* Effect.promise(() =>
234+
handler(
235+
new Request("http://localhost/api/voice/models/link", {
236+
method: "POST",
237+
headers: { "content-type": "application/json" },
238+
body: JSON.stringify({ path: "/models" }),
239+
}),
240+
context,
241+
),
242+
);
243+
expect(link.status).toBe(403);
232244
}).pipe(Effect.scoped),
233245
);
234246

‎apps/server/src/speech/http.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { decodeSpeechPcmRequest } from "@t3tools/shared/speech";
22
import {
3+
AuthFilesystemReadScope,
34
AuthOrchestrationOperateScope,
45
AuthSettingsWriteScope,
56
type AuthEnvironmentScope,
@@ -168,6 +169,8 @@ export const speechHttpApiLayer = HttpApiBuilder.group(
168169
"linkModels",
169170
Effect.fn("environment.voice.linkModels")(function* (args) {
170171
yield* guard(args.endpoint.name, AuthSettingsWriteScope);
172+
// The request names a path on this machine and the result reveals what is there.
173+
yield* requireEnvironmentScope(AuthFilesystemReadScope);
171174
return yield* speech.linkModels(args.payload.path).pipe(mapSpeechError);
172175
}),
173176
)

‎apps/web/src/components/settings/VoiceSettingsPanel.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import type { PreparedConnection } from "@t3tools/client-runtime/connection";
1313
import { openRouterIcon } from "@t3tools/client-runtime/provider-clients";
1414
import {
15+
AuthFilesystemReadScope,
1516
AuthSettingsWriteScope,
1617
CLOUD_SPEECH_LABELS,
1718
CloudSpeechProvider,
@@ -795,6 +796,7 @@ function SpeechModelBrowser(props: {
795796
const [sort, setSort] = useState<SpeechModelSort>("recommended");
796797
const [downloadedOnly, setDownloadedOnly] = useState(false);
797798
const [existingPath, setExistingPath] = useState("");
799+
const canReadFiles = useEnvironmentScope(prepared.environmentId, AuthFilesystemReadScope);
798800
const [languageSearch, setLanguageSearch] = useState("");
799801
const [selection, setSelection] = useState<string[] | null>(null);
800802
const [preferred] = useState(preferredSpeechLanguages);
@@ -997,12 +999,12 @@ function SpeechModelBrowser(props: {
997999
);
9981000
})}
9991001
</div>
1000-
{status.supportsModelLinking ? (
1002+
{status.supportsModelLinking && canReadFiles ? (
10011003
<form
10021004
className="flex items-center gap-2 border-t border-border/60 p-3"
10031005
onSubmit={(event) => {
10041006
event.preventDefault();
1005-
void run("No new supported model found at that path", async () => {
1007+
void run("Could not use a model from that path", async () => {
10061008
await runEnvironmentSpeech(
10071009
linkEnvironmentSpeechModels(prepared, existingPath),
10081010
);

0 commit comments

Comments
 (0)