Skip to content

Commit 4afbc37

Browse files
alex-spataruclaude
andcommitted
fix(ai): repair Windows fs sandbox path gate; run read-side fs ops off the GUI thread
The sandbox membership check compared Qt canonical paths (always '/') against a prefix built with QDir::separator() ('\' on Windows), so every non-root path was rejected as outside_sandbox: all writes failed and listing any named subfolder failed, while root-anchored list/search worked because they bypass the gate. Compare against '/' instead. fs.list/fs.read/fs.search walked the workspace and opened files synchronously on the Conversation's main thread, freezing the GUI on large or cloud-backed (OneDrive) workspaces. Run those read-side ops on a worker thread while the caller pumps a nested event loop, and guard the dropped-path list with a mutex since it is now read off-thread. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e3ce6bf commit 4afbc37

4 files changed

Lines changed: 35 additions & 6 deletions

File tree

app/rcc/ai/search_index.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

app/src/AI/FileSandbox.cpp

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ static QString displayPath(const QString& absolute, const QString& root)
6565
if (absolute == root)
6666
return QStringLiteral(".");
6767

68-
const auto prefix = root + QDir::separator();
68+
const auto prefix = root + QLatin1Char('/');
6969
if (absolute.startsWith(prefix))
7070
return absolute.mid(prefix.size());
7171

@@ -103,6 +103,7 @@ QString AI::FileSandbox::writeRoot() const
103103
*/
104104
QStringList AI::FileSandbox::droppedPaths() const
105105
{
106+
const QMutexLocker locker(&m_dropMutex);
106107
return m_droppedPaths;
107108
}
108109

@@ -116,6 +117,7 @@ QStringList AI::FileSandbox::readRoots() const
116117
if (!ws.isEmpty())
117118
roots.append(ws);
118119

120+
const QMutexLocker locker(&m_dropMutex);
119121
for (const auto& dropped : m_droppedPaths)
120122
if (!dropped.isEmpty() && !roots.contains(dropped))
121123
roots.append(dropped);
@@ -170,7 +172,7 @@ bool AI::FileSandbox::isWithinRoot(const QString& canonical, const QString& root
170172
if (canonical == root)
171173
return true;
172174

173-
return canonical.startsWith(root + QDir::separator());
175+
return canonical.startsWith(root + QLatin1Char('/'));
174176
}
175177

176178
/**
@@ -275,6 +277,7 @@ QString AI::FileSandbox::registerDroppedPath(const QString& localPath)
275277
if (canonical.isEmpty())
276278
return {};
277279

280+
const QMutexLocker locker(&m_dropMutex);
278281
if (!m_droppedPaths.contains(canonical)) {
279282
m_droppedPaths.append(canonical);
280283
qCDebug(serialStudioAI) << "Sandbox: registered dropped path" << canonical;
@@ -288,6 +291,7 @@ QString AI::FileSandbox::registerDroppedPath(const QString& localPath)
288291
*/
289292
void AI::FileSandbox::clearDroppedPaths()
290293
{
294+
const QMutexLocker locker(&m_dropMutex);
291295
m_droppedPaths.clear();
292296
}
293297

app/src/AI/FileSandbox.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#pragma once
1313

1414
#include <QJsonObject>
15+
#include <QMutex>
1516
#include <QString>
1617
#include <QStringList>
1718

@@ -70,6 +71,7 @@ class FileSandbox {
7071
[[nodiscard]] static bool isWithinRoot(const QString& canonical, const QString& root);
7172

7273
QStringList m_droppedPaths;
74+
mutable QMutex m_dropMutex;
7375
};
7476

7577
} // namespace AI

app/src/AI/ToolDispatcher.cpp

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@
88

99
#include "AI/ToolDispatcher.h"
1010

11+
#include <functional>
12+
#include <QCoreApplication>
13+
#include <QEventLoop>
1114
#include <QFile>
1215
#include <QHash>
1316
#include <QStringList>
17+
#include <QThread>
1418
#include <QUuid>
1519
#include <QVector>
1620

@@ -572,20 +576,39 @@ static QJsonObject fsToolDescription(const QString& name)
572576
return {};
573577
}
574578

579+
/**
580+
* @brief Runs blocking work on a worker thread, pumping the caller's event loop until it returns.
581+
*/
582+
static QJsonObject runOffMainThread(const std::function<QJsonObject()>& work)
583+
{
584+
if (QThread::currentThread() != QCoreApplication::instance()->thread())
585+
return work();
586+
587+
QJsonObject result;
588+
QEventLoop loop;
589+
QThread* worker = QThread::create([&]() { result = work(); });
590+
QObject::connect(worker, &QThread::finished, &loop, &QEventLoop::quit);
591+
worker->start();
592+
loop.exec();
593+
worker->wait();
594+
delete worker;
595+
return result;
596+
}
597+
575598
/**
576599
* @brief Routes an fs.* tool call to the FileSandbox primitive.
577600
*/
578601
static QJsonObject executeFsTool(const QString& name, const QJsonObject& args)
579602
{
580603
auto& sandbox = AI::FileSandbox::instance();
581604
if (name == QStringLiteral("fs.list"))
582-
return sandbox.list(args);
605+
return runOffMainThread([&]() { return sandbox.list(args); });
583606

584607
if (name == QStringLiteral("fs.read"))
585-
return sandbox.read(args);
608+
return runOffMainThread([&]() { return sandbox.read(args); });
586609

587610
if (name == QStringLiteral("fs.search"))
588-
return sandbox.search(args);
611+
return runOffMainThread([&]() { return sandbox.search(args); });
589612

590613
if (name == QStringLiteral("fs.write"))
591614
return sandbox.write(args);

0 commit comments

Comments
 (0)