glad-web 1.0.45 → 1.0.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/codex/structured-session.js +15 -3
- package/lib/commands/web.js +23 -4
- package/lib/config/manager.js +19 -0
- package/lib/server/routes/skillhub.js +104 -0
- package/lib/session/session-manager.js +71 -40
- package/lib/skillhub/client.js +121 -0
- package/lib/skillhub/settings-store.js +168 -0
- package/lib/skillhub/skill-installer.js +320 -0
- package/lib/web/bootstrap.js +34 -0
- package/lib/web/claude.js +29 -8
- package/lib/web/codex.js +5 -2
- package/lib/web/composer.js +30 -0
- package/lib/web/core.js +47 -35
- package/lib/web/index.html +43 -12
- package/lib/web/layout.js +4 -7
- package/lib/web/notifications.js +1 -0
- package/lib/web/session.js +3 -2
- package/lib/web/shell.js +17 -2
- package/lib/web/skillhub.js +197 -0
- package/lib/web/styles.css +35 -8
- package/package.json +6 -3
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const {
|
|
4
|
+
getConfig,
|
|
5
|
+
setConfig,
|
|
6
|
+
getConfigPath
|
|
7
|
+
} = require('../config/manager');
|
|
8
|
+
|
|
9
|
+
function problem(message, statusCode = 400, code = 'SKILLHUB_INVALID_SETTINGS') {
|
|
10
|
+
const error = new Error(message);
|
|
11
|
+
error.statusCode = statusCode;
|
|
12
|
+
error.code = code;
|
|
13
|
+
return error;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeBaseUrl(value) {
|
|
17
|
+
const raw = String(value || '').trim();
|
|
18
|
+
if (!raw || raw.length > 2048) throw problem('请输入有效的 SkillHub 地址');
|
|
19
|
+
let url;
|
|
20
|
+
try { url = new URL(raw); } catch (_) { throw problem('请输入有效的 SkillHub 地址'); }
|
|
21
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password
|
|
22
|
+
|| url.search || url.hash) {
|
|
23
|
+
throw problem('SkillHub 地址格式无效');
|
|
24
|
+
}
|
|
25
|
+
const localHosts = new Set(['skillhub', 'localhost', '127.0.0.1', '::1']);
|
|
26
|
+
if (url.protocol === 'http:' && !localHosts.has(url.hostname.toLowerCase())) {
|
|
27
|
+
throw problem('远程 SkillHub 必须使用 HTTPS');
|
|
28
|
+
}
|
|
29
|
+
url.pathname = url.pathname.replace(/\/+$/, '');
|
|
30
|
+
return url.toString().replace(/\/$/, '');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeToken(value) {
|
|
34
|
+
const token = String(value || '').trim();
|
|
35
|
+
if (!token || token.length < 12 || token.length > 2048 || /\s/.test(token)) {
|
|
36
|
+
throw problem('请输入有效的 SkillHub API Token');
|
|
37
|
+
}
|
|
38
|
+
return token;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function maskToken(token) {
|
|
42
|
+
if (!token) return '';
|
|
43
|
+
return `${token.slice(0, Math.min(7, token.length))}${'•'.repeat(10)}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function decodeKey(content) {
|
|
47
|
+
const raw = Buffer.isBuffer(content) ? content : Buffer.from(String(content || ''), 'utf8');
|
|
48
|
+
const text = raw.toString('utf8').trim();
|
|
49
|
+
if (/^[0-9a-f]{64}$/i.test(text)) return Buffer.from(text, 'hex');
|
|
50
|
+
if (/^[A-Za-z0-9+/]{43}=$/.test(text)) return Buffer.from(text, 'base64');
|
|
51
|
+
if (raw.length === 32) return raw;
|
|
52
|
+
throw problem('SkillHub Token 加密密钥必须是 32 字节', 500, 'SKILLHUB_KEY_INVALID');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
class SkillHubSettingsStore {
|
|
56
|
+
constructor({
|
|
57
|
+
readConfig = getConfig,
|
|
58
|
+
writeConfig = setConfig,
|
|
59
|
+
configPath = getConfigPath,
|
|
60
|
+
keyFile = process.env.GLAD_SKILLHUB_KEY_FILE || '',
|
|
61
|
+
readFile = fs.readFileSync,
|
|
62
|
+
chmod = fs.chmodSync
|
|
63
|
+
} = {}) {
|
|
64
|
+
this.readConfig = readConfig;
|
|
65
|
+
this.writeConfig = writeConfig;
|
|
66
|
+
this.configPath = configPath;
|
|
67
|
+
this.keyFile = keyFile;
|
|
68
|
+
this.readFile = readFile;
|
|
69
|
+
this.chmod = chmod;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
key() {
|
|
73
|
+
if (!this.keyFile) {
|
|
74
|
+
throw problem('Glad 未配置 SkillHub Token 加密密钥', 503, 'SKILLHUB_KEY_MISSING');
|
|
75
|
+
}
|
|
76
|
+
try { return decodeKey(this.readFile(this.keyFile)); }
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (error.code === 'SKILLHUB_KEY_INVALID') throw error;
|
|
79
|
+
throw problem('Glad 无法读取 SkillHub Token 加密密钥', 503, 'SKILLHUB_KEY_UNREADABLE');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
encrypt(token) {
|
|
84
|
+
const iv = crypto.randomBytes(12);
|
|
85
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', this.key(), iv);
|
|
86
|
+
const ciphertext = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()]);
|
|
87
|
+
return {
|
|
88
|
+
ciphertext: ciphertext.toString('base64'),
|
|
89
|
+
iv: iv.toString('base64'),
|
|
90
|
+
authTag: cipher.getAuthTag().toString('base64')
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
decrypt(envelope) {
|
|
95
|
+
if (!envelope?.ciphertext || !envelope?.iv || !envelope?.authTag) return '';
|
|
96
|
+
try {
|
|
97
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', this.key(), Buffer.from(envelope.iv, 'base64'));
|
|
98
|
+
decipher.setAuthTag(Buffer.from(envelope.authTag, 'base64'));
|
|
99
|
+
return Buffer.concat([
|
|
100
|
+
decipher.update(Buffer.from(envelope.ciphertext, 'base64')),
|
|
101
|
+
decipher.final()
|
|
102
|
+
]).toString('utf8');
|
|
103
|
+
} catch (_) {
|
|
104
|
+
throw problem('SkillHub Token 解密失败,请重新配置', 503, 'SKILLHUB_TOKEN_DECRYPT_FAILED');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
get() {
|
|
109
|
+
const stored = this.readConfig('skillHub') || {};
|
|
110
|
+
const baseUrl = String(stored.baseUrl || '').trim();
|
|
111
|
+
const token = this.decrypt(stored.token || {});
|
|
112
|
+
return { baseUrl, token };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
getPublic() {
|
|
116
|
+
const settings = this.get();
|
|
117
|
+
return {
|
|
118
|
+
configured: Boolean(settings.baseUrl && settings.token),
|
|
119
|
+
baseUrl: settings.baseUrl,
|
|
120
|
+
maskedToken: maskToken(settings.token)
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
resolve(input = {}) {
|
|
125
|
+
const existing = this.get();
|
|
126
|
+
return {
|
|
127
|
+
baseUrl: normalizeBaseUrl(input.baseUrl ?? existing.baseUrl),
|
|
128
|
+
token: input.token == null || String(input.token).trim() === ''
|
|
129
|
+
? normalizeToken(existing.token)
|
|
130
|
+
: normalizeToken(input.token)
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
save(input = {}) {
|
|
135
|
+
const settings = this.resolve(input);
|
|
136
|
+
this.writeConfig('skillHub', {
|
|
137
|
+
baseUrl: settings.baseUrl,
|
|
138
|
+
token: this.encrypt(settings.token)
|
|
139
|
+
});
|
|
140
|
+
this.restrictConfigFile();
|
|
141
|
+
return this.getPublic();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
clear() {
|
|
145
|
+
this.writeConfig('skillHub', {
|
|
146
|
+
baseUrl: '',
|
|
147
|
+
token: { ciphertext: '', iv: '', authTag: '' }
|
|
148
|
+
});
|
|
149
|
+
this.restrictConfigFile();
|
|
150
|
+
return { configured: false, baseUrl: '', maskedToken: '' };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
restrictConfigFile() {
|
|
154
|
+
try {
|
|
155
|
+
const target = typeof this.configPath === 'function' ? this.configPath() : this.configPath;
|
|
156
|
+
if (target && fs.existsSync(target)) this.chmod(target, 0o600);
|
|
157
|
+
} catch (_) {
|
|
158
|
+
// 部分文件系统不支持 chmod,配置仍可使用。
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = {
|
|
164
|
+
SkillHubSettingsStore,
|
|
165
|
+
normalizeBaseUrl,
|
|
166
|
+
normalizeToken,
|
|
167
|
+
maskToken
|
|
168
|
+
};
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { spawnSync } = require('child_process');
|
|
5
|
+
const yauzl = require('yauzl');
|
|
6
|
+
const YAML = require('yaml');
|
|
7
|
+
|
|
8
|
+
const MAX_FILES = 256;
|
|
9
|
+
const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
10
|
+
const MAX_TOTAL_BYTES = 20 * 1024 * 1024;
|
|
11
|
+
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
12
|
+
|
|
13
|
+
function installProblem(message, statusCode = 400, code = 'SKILLHUB_INVALID_BUNDLE') {
|
|
14
|
+
const error = new Error(message);
|
|
15
|
+
error.statusCode = statusCode;
|
|
16
|
+
error.code = code;
|
|
17
|
+
return error;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function safeRelativePath(value) {
|
|
21
|
+
const raw = String(value || '');
|
|
22
|
+
if (!raw || raw.includes('\\') || raw.includes('\0') || raw.startsWith('/')) return null;
|
|
23
|
+
const normalized = path.posix.normalize(raw);
|
|
24
|
+
if (normalized === '.' || normalized === '..' || normalized.startsWith('../')) return null;
|
|
25
|
+
if (normalized.split('/').some(part => !part || part === '.' || part === '..')) return null;
|
|
26
|
+
return normalized;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseSkillName(markdown) {
|
|
30
|
+
const frontmatter = String(markdown || '').match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
31
|
+
if (!frontmatter) throw installProblem('SKILL.md 缺少 frontmatter');
|
|
32
|
+
const match = frontmatter[1].match(/^name\s*:\s*(.+?)\s*$/m);
|
|
33
|
+
if (!match) throw installProblem('SKILL.md 缺少 name');
|
|
34
|
+
const name = match[1].trim().replace(/^(['"])(.*)\1$/, '$2');
|
|
35
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,127}$/i.test(name)) {
|
|
36
|
+
throw installProblem('SKILL.md name 格式无效');
|
|
37
|
+
}
|
|
38
|
+
return name;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseDefaultPrompt(content) {
|
|
42
|
+
try {
|
|
43
|
+
const document = YAML.parse(String(content || ''));
|
|
44
|
+
const prompt = document?.interface?.default_prompt;
|
|
45
|
+
if (typeof prompt !== 'string') return '';
|
|
46
|
+
const normalized = prompt.trim();
|
|
47
|
+
return normalized.length <= 8000 ? normalized : '';
|
|
48
|
+
} catch (_) {
|
|
49
|
+
return '';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function mountTmpfs(root) {
|
|
54
|
+
try {
|
|
55
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
|
|
56
|
+
const gid = typeof process.getgid === 'function' ? process.getgid() : 0;
|
|
57
|
+
const result = spawnSync('mount', [
|
|
58
|
+
'-t', 'tmpfs',
|
|
59
|
+
'-o', `rw,nosuid,nodev,noexec,size=128m,mode=0700,uid=${uid},gid=${gid}`,
|
|
60
|
+
'tmpfs', root
|
|
61
|
+
], { stdio: 'ignore', timeout: 2000 });
|
|
62
|
+
return result.status === 0;
|
|
63
|
+
} catch (_) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function openZip(buffer) {
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
yauzl.fromBuffer(buffer, { lazyEntries: true, validateEntrySizes: true }, (error, zip) => {
|
|
71
|
+
if (error) reject(installProblem(`Skill bundle 无法解析:${error.message}`));
|
|
72
|
+
else resolve(zip);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readEntry(zip, entry) {
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
zip.openReadStream(entry, (error, stream) => {
|
|
80
|
+
if (error) return reject(error);
|
|
81
|
+
const chunks = [];
|
|
82
|
+
let size = 0;
|
|
83
|
+
stream.on('data', chunk => {
|
|
84
|
+
size += chunk.length;
|
|
85
|
+
if (size > MAX_FILE_BYTES) stream.destroy(installProblem('Skill 单文件超过 10 MB', 413));
|
|
86
|
+
else chunks.push(chunk);
|
|
87
|
+
});
|
|
88
|
+
stream.once('error', reject);
|
|
89
|
+
stream.once('end', () => resolve(Buffer.concat(chunks)));
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function extractVerifiedBundle(buffer, manifest, destination) {
|
|
95
|
+
const declaredFiles = Array.isArray(manifest?.files) ? manifest.files : [];
|
|
96
|
+
if (!declaredFiles.length || declaredFiles.length > MAX_FILES) {
|
|
97
|
+
throw installProblem(`Skill manifest 文件数必须在 1-${MAX_FILES} 之间`);
|
|
98
|
+
}
|
|
99
|
+
const expected = new Map();
|
|
100
|
+
let declaredTotal = 0;
|
|
101
|
+
for (const file of declaredFiles) {
|
|
102
|
+
const filePath = safeRelativePath(file?.path);
|
|
103
|
+
const size = Number(file?.size);
|
|
104
|
+
const sha256 = String(file?.sha256 || '').toLowerCase();
|
|
105
|
+
if (!filePath || !Number.isSafeInteger(size) || size < 0 || size > MAX_FILE_BYTES
|
|
106
|
+
|| !/^[0-9a-f]{64}$/.test(sha256) || expected.has(filePath)) {
|
|
107
|
+
throw installProblem('Skill manifest 文件声明无效');
|
|
108
|
+
}
|
|
109
|
+
declaredTotal += size;
|
|
110
|
+
if (declaredTotal > MAX_TOTAL_BYTES) throw installProblem('Skill 文件总大小超过 20 MB', 413);
|
|
111
|
+
expected.set(filePath, { size, sha256 });
|
|
112
|
+
}
|
|
113
|
+
if (!expected.has('SKILL.md')) throw installProblem('Skill bundle 缺少 SKILL.md');
|
|
114
|
+
|
|
115
|
+
await fs.promises.mkdir(destination, { recursive: true, mode: 0o700 });
|
|
116
|
+
const zip = await openZip(buffer);
|
|
117
|
+
const written = new Set();
|
|
118
|
+
let archivePrefix = null;
|
|
119
|
+
try {
|
|
120
|
+
await new Promise((resolve, reject) => {
|
|
121
|
+
zip.once('error', reject);
|
|
122
|
+
zip.once('end', resolve);
|
|
123
|
+
zip.on('entry', entry => {
|
|
124
|
+
void (async () => {
|
|
125
|
+
const rawName = String(entry.fileName || '');
|
|
126
|
+
if (rawName.endsWith('/')) {
|
|
127
|
+
const directory = safeRelativePath(rawName.slice(0, -1));
|
|
128
|
+
if (!directory) throw installProblem(`Skill bundle 包含不安全目录:${rawName}`);
|
|
129
|
+
zip.readEntry();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
let filePath = safeRelativePath(rawName);
|
|
133
|
+
if (filePath && !expected.has(filePath)) {
|
|
134
|
+
const separator = filePath.indexOf('/');
|
|
135
|
+
const prefix = separator > 0 ? filePath.slice(0, separator) : '';
|
|
136
|
+
const nested = separator > 0 ? filePath.slice(separator + 1) : '';
|
|
137
|
+
if (prefix && expected.has(nested) && (archivePrefix == null || archivePrefix === prefix)) {
|
|
138
|
+
archivePrefix = prefix;
|
|
139
|
+
filePath = nested;
|
|
140
|
+
}
|
|
141
|
+
} else if (filePath && expected.has(filePath)) {
|
|
142
|
+
if (archivePrefix && archivePrefix !== '') {
|
|
143
|
+
throw installProblem('Skill bundle 同时包含带前缀和无前缀文件');
|
|
144
|
+
}
|
|
145
|
+
if (archivePrefix == null) archivePrefix = '';
|
|
146
|
+
}
|
|
147
|
+
const unixType = (entry.externalFileAttributes >>> 16) & 0xf000;
|
|
148
|
+
if (!filePath || unixType === 0xa000 || !expected.has(filePath) || written.has(filePath)) {
|
|
149
|
+
throw installProblem(`Skill bundle 包含未声明或不安全文件:${rawName}`);
|
|
150
|
+
}
|
|
151
|
+
const content = await readEntry(zip, entry);
|
|
152
|
+
const declared = expected.get(filePath);
|
|
153
|
+
const digest = crypto.createHash('sha256').update(content).digest('hex');
|
|
154
|
+
if (content.length !== declared.size || digest !== declared.sha256) {
|
|
155
|
+
throw installProblem(`Skill 文件校验失败:${filePath}`);
|
|
156
|
+
}
|
|
157
|
+
const target = path.join(destination, ...filePath.split('/'));
|
|
158
|
+
await fs.promises.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
159
|
+
const originalMode = (entry.externalFileAttributes >>> 16) & 0o777;
|
|
160
|
+
const mode = originalMode & 0o111 ? 0o700 : 0o600;
|
|
161
|
+
await fs.promises.writeFile(target, content, { mode, flag: 'wx' });
|
|
162
|
+
written.add(filePath);
|
|
163
|
+
zip.readEntry();
|
|
164
|
+
})().catch(reject);
|
|
165
|
+
});
|
|
166
|
+
zip.readEntry();
|
|
167
|
+
});
|
|
168
|
+
} finally {
|
|
169
|
+
try { zip.close(); } catch (_) { /* already closed */ }
|
|
170
|
+
}
|
|
171
|
+
if (written.size !== expected.size) {
|
|
172
|
+
const missing = [...expected.keys()].filter(item => !written.has(item));
|
|
173
|
+
throw installProblem(`Skill bundle 缺少文件:${missing.join(', ')}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
class SkillInstaller {
|
|
178
|
+
constructor({
|
|
179
|
+
client,
|
|
180
|
+
root = process.env.GLAD_SKILL_SESSION_ROOT || '/run/glad-skill-sessions',
|
|
181
|
+
readMounts = () => fs.readFileSync('/proc/mounts', 'utf8'),
|
|
182
|
+
tryMountTmpfs = mountTmpfs,
|
|
183
|
+
available = null
|
|
184
|
+
} = {}) {
|
|
185
|
+
this.client = client;
|
|
186
|
+
this.root = path.resolve(root);
|
|
187
|
+
this.readMounts = readMounts;
|
|
188
|
+
this.tryMountTmpfs = tryMountTmpfs;
|
|
189
|
+
this.available = available;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
sessionRoot(sessionId) {
|
|
193
|
+
if (!SESSION_ID.test(String(sessionId || ''))) throw installProblem('Glad Session ID 无效');
|
|
194
|
+
return path.join(this.root, sessionId);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async initialize() {
|
|
198
|
+
this.available = this.isTmpfsMounted();
|
|
199
|
+
if (!this.available) {
|
|
200
|
+
let created = false;
|
|
201
|
+
try {
|
|
202
|
+
if (!fs.existsSync(this.root)) {
|
|
203
|
+
await fs.promises.mkdir(this.root, { recursive: true, mode: 0o700 });
|
|
204
|
+
created = true;
|
|
205
|
+
}
|
|
206
|
+
this.tryMountTmpfs(this.root);
|
|
207
|
+
this.available = this.isTmpfsMounted();
|
|
208
|
+
} catch (_) {
|
|
209
|
+
this.available = false;
|
|
210
|
+
}
|
|
211
|
+
if (!this.available) {
|
|
212
|
+
if (created) {
|
|
213
|
+
try { fs.rmdirSync(this.root); } catch (_) { /* 只清理本次创建的空目录 */ }
|
|
214
|
+
}
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
await fs.promises.mkdir(this.root, { recursive: true, mode: 0o700 });
|
|
219
|
+
const entries = await fs.promises.readdir(this.root, { withFileTypes: true });
|
|
220
|
+
for (const entry of entries) {
|
|
221
|
+
if (entry.isDirectory() && SESSION_ID.test(entry.name)) {
|
|
222
|
+
await fs.promises.rm(path.join(this.root, entry.name), { recursive: true, force: true });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
isTmpfsMounted() {
|
|
229
|
+
try {
|
|
230
|
+
const rows = String(this.readMounts() || '').split(/\r?\n/);
|
|
231
|
+
return rows.some(row => {
|
|
232
|
+
const fields = row.trim().split(/\s+/);
|
|
233
|
+
const mountPoint = String(fields[1] || '').replace(/\\040/g, ' ');
|
|
234
|
+
return mountPoint === this.root && fields[2] === 'tmpfs';
|
|
235
|
+
});
|
|
236
|
+
} catch (_) {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
assertAvailable() {
|
|
242
|
+
if (this.available === true) return;
|
|
243
|
+
throw installProblem('Skill暂不可用', 503, 'SKILL_TEMPORARILY_UNAVAILABLE');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async prepare(sessionId, selection) {
|
|
247
|
+
this.assertAvailable();
|
|
248
|
+
const id = String(selection?.id || '').trim();
|
|
249
|
+
if (!/^[0-9a-f-]{36}$/i.test(id)) throw installProblem('Skill ID 无效');
|
|
250
|
+
const detail = await this.client.getSkill({
|
|
251
|
+
id,
|
|
252
|
+
version: String(selection?.version || ''),
|
|
253
|
+
digest: String(selection?.digest || '')
|
|
254
|
+
});
|
|
255
|
+
if (!detail?.manifest || detail.id !== id || !detail.version || !detail.digest) {
|
|
256
|
+
throw installProblem('SkillHub Skill 详情格式无效', 502);
|
|
257
|
+
}
|
|
258
|
+
if (selection.version && detail.version !== selection.version) throw installProblem('Skill 版本已变更', 409);
|
|
259
|
+
if (selection.digest && detail.digest !== selection.digest) throw installProblem('Skill 内容已变更', 409);
|
|
260
|
+
if (detail.manifest.manifestDigest !== detail.digest) throw installProblem('Skill manifest digest 不一致', 502);
|
|
261
|
+
|
|
262
|
+
const downloaded = await this.client.downloadBundle({
|
|
263
|
+
id,
|
|
264
|
+
version: detail.version,
|
|
265
|
+
digest: detail.digest
|
|
266
|
+
});
|
|
267
|
+
if (downloaded.digest && downloaded.digest !== detail.digest) {
|
|
268
|
+
throw installProblem('Skill bundle digest 不一致', 502);
|
|
269
|
+
}
|
|
270
|
+
if (downloaded.sha256) {
|
|
271
|
+
const actual = crypto.createHash('sha256').update(downloaded.buffer).digest('hex');
|
|
272
|
+
if (actual !== downloaded.sha256.toLowerCase()) throw installProblem('Skill bundle SHA256 校验失败', 502);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const sessionRoot = this.sessionRoot(sessionId);
|
|
276
|
+
const skillsRoot = path.join(sessionRoot, 'skills');
|
|
277
|
+
const temporary = path.join(sessionRoot, `.extract-${crypto.randomUUID()}`);
|
|
278
|
+
const skillDirectory = path.join(skillsRoot, id);
|
|
279
|
+
try {
|
|
280
|
+
await fs.promises.mkdir(sessionRoot, { recursive: true, mode: 0o700 });
|
|
281
|
+
await extractVerifiedBundle(downloaded.buffer, detail.manifest, temporary);
|
|
282
|
+
const skillMd = await fs.promises.readFile(path.join(temporary, 'SKILL.md'), 'utf8');
|
|
283
|
+
const name = parseSkillName(skillMd);
|
|
284
|
+
let defaultPrompt = '';
|
|
285
|
+
const openAIMetadata = detail.manifest.files.find(file => file?.path === 'agents/openai.yaml');
|
|
286
|
+
if (openAIMetadata && Number(openAIMetadata.size) <= 64 * 1024) {
|
|
287
|
+
const metadata = await fs.promises.readFile(path.join(temporary, 'agents/openai.yaml'), 'utf8');
|
|
288
|
+
defaultPrompt = parseDefaultPrompt(metadata);
|
|
289
|
+
}
|
|
290
|
+
await fs.promises.mkdir(skillsRoot, { recursive: true, mode: 0o700 });
|
|
291
|
+
await fs.promises.rename(temporary, skillDirectory);
|
|
292
|
+
return {
|
|
293
|
+
id,
|
|
294
|
+
name,
|
|
295
|
+
version: detail.version,
|
|
296
|
+
digest: detail.digest,
|
|
297
|
+
defaultPrompt,
|
|
298
|
+
skillsRoot,
|
|
299
|
+
path: path.join(skillDirectory, 'SKILL.md')
|
|
300
|
+
};
|
|
301
|
+
} catch (error) {
|
|
302
|
+
await fs.promises.rm(sessionRoot, { recursive: true, force: true });
|
|
303
|
+
throw error;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
cleanupSync(sessionId) {
|
|
308
|
+
if (this.available === false) return;
|
|
309
|
+
const target = this.sessionRoot(sessionId);
|
|
310
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
module.exports = {
|
|
315
|
+
SkillInstaller,
|
|
316
|
+
extractVerifiedBundle,
|
|
317
|
+
parseSkillName,
|
|
318
|
+
safeRelativePath,
|
|
319
|
+
parseDefaultPrompt
|
|
320
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const splitQuery = '(min-width: 920px)';
|
|
3
|
+
const sidebarStorageKey = 'glad-sidebar-width';
|
|
4
|
+
|
|
5
|
+
function clampSidebarWidth(value) {
|
|
6
|
+
const max = Math.max(300, Math.min(480, window.innerWidth - 484));
|
|
7
|
+
return Math.min(max, Math.max(300, Number(value) || 348));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function applySidebarWidth(value) {
|
|
11
|
+
const width = clampSidebarWidth(value);
|
|
12
|
+
document.documentElement.style.setProperty('--sidebar-w', `${width}px`);
|
|
13
|
+
return width;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
window.gladLayout = Object.freeze({
|
|
17
|
+
splitQuery,
|
|
18
|
+
sidebarStorageKey,
|
|
19
|
+
clampSidebarWidth,
|
|
20
|
+
applySidebarWidth
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const storedTheme = localStorage.getItem('glad-theme');
|
|
24
|
+
const theme = storedTheme === 'light' || storedTheme === 'dark'
|
|
25
|
+
? storedTheme
|
|
26
|
+
: (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
|
27
|
+
document.documentElement.dataset.theme = theme;
|
|
28
|
+
document.documentElement.style.backgroundColor = theme === 'dark' ? '#000000' : '#f5f6f8';
|
|
29
|
+
|
|
30
|
+
const storedSidebarWidth = Number(localStorage.getItem(sidebarStorageKey));
|
|
31
|
+
if (Number.isFinite(storedSidebarWidth) && storedSidebarWidth > 0) {
|
|
32
|
+
applySidebarWidth(storedSidebarWidth);
|
|
33
|
+
}
|
|
34
|
+
})();
|
package/lib/web/claude.js
CHANGED
|
@@ -247,9 +247,12 @@
|
|
|
247
247
|
}
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
-
function applyClaudeState(state = {}) {
|
|
250
|
+
function applyClaudeState(state = {}, options = {}) {
|
|
251
251
|
claudeState = { ...claudeState, ...state };
|
|
252
252
|
claudeStatus = claudeState.status || claudeStatus;
|
|
253
|
+
if (typeof syncComposerSendState === 'function') {
|
|
254
|
+
syncComposerSendState({ acknowledgeProviderState: Boolean(options.providerStateReceived) });
|
|
255
|
+
}
|
|
253
256
|
const permissionEl = document.getElementById('claude-permission-select');
|
|
254
257
|
const modelEl = document.getElementById('claude-model-select');
|
|
255
258
|
const effortEl = document.getElementById('claude-effort-select');
|
|
@@ -1085,13 +1088,13 @@
|
|
|
1085
1088
|
} else if (event.type === 'history-reset' && Array.isArray(event.messages)) {
|
|
1086
1089
|
claudeMessages = event.messages;
|
|
1087
1090
|
} else if (event.type === 'state' && event.state) {
|
|
1088
|
-
applyClaudeState(event.state);
|
|
1091
|
+
applyClaudeState(event.state, { providerStateReceived: true });
|
|
1089
1092
|
}
|
|
1090
1093
|
if (event.type !== 'state') applyClaudeState({
|
|
1091
1094
|
status: claudeStatus,
|
|
1092
1095
|
pendingPermissionCount: claudePendingPermissions.filter(item => item.status === 'pending').length,
|
|
1093
1096
|
canAbort: claudeStatus === 'thinking'
|
|
1094
|
-
});
|
|
1097
|
+
}, { providerStateReceived: event.type === 'status' });
|
|
1095
1098
|
renderClaudeChat();
|
|
1096
1099
|
}
|
|
1097
1100
|
|
|
@@ -1109,6 +1112,7 @@
|
|
|
1109
1112
|
}
|
|
1110
1113
|
|
|
1111
1114
|
async function createSession(toolKey, sessionName) {
|
|
1115
|
+
const selectedSkill = window.pendingSkillHubSkill || null;
|
|
1112
1116
|
try {
|
|
1113
1117
|
const workingDirectory = document.getElementById('cwd-field').value;
|
|
1114
1118
|
const runtimeConfig = toolKey === 'claude-code' ? await refreshClaudeRuntimeConfig() : null;
|
|
@@ -1116,14 +1120,31 @@
|
|
|
1116
1120
|
model: runtimeConfig.defaultModel || 'default',
|
|
1117
1121
|
effort: runtimeConfig.defaultEffort || 'medium'
|
|
1118
1122
|
} : undefined;
|
|
1119
|
-
const
|
|
1123
|
+
const endpoint = selectedSkill ? '/api/skillhub/sessions' : '/api/sessions';
|
|
1124
|
+
const body = selectedSkill ? {
|
|
1125
|
+
toolKey,
|
|
1126
|
+
workingDirectory,
|
|
1127
|
+
skill: {
|
|
1128
|
+
id: selectedSkill.id,
|
|
1129
|
+
version: selectedSkill.version,
|
|
1130
|
+
digest: selectedSkill.digest
|
|
1131
|
+
}
|
|
1132
|
+
} : { toolKey, workingDirectory, claudeOptions };
|
|
1133
|
+
const list = document.getElementById('tools-list');
|
|
1134
|
+
if (selectedSkill) list.innerHTML = '<p class="skill-hall-status">Downloading and verifying Skill…</p>';
|
|
1135
|
+
const res = await fetchWithTimeout(endpoint, {
|
|
1120
1136
|
method: 'POST',
|
|
1121
1137
|
headers: { 'Content-Type': 'application/json' },
|
|
1122
|
-
body: JSON.stringify(
|
|
1123
|
-
});
|
|
1138
|
+
body: JSON.stringify(body)
|
|
1139
|
+
}, selectedSkill ? 70000 : 30000);
|
|
1124
1140
|
const data = await res.json();
|
|
1125
1141
|
if (!res.ok || !data.id) throw new Error(data.error || 'Failed to create session');
|
|
1126
1142
|
document.getElementById('modal-overlay').style.display = 'none';
|
|
1127
|
-
|
|
1128
|
-
|
|
1143
|
+
window.pendingSkillHubSkill = null;
|
|
1144
|
+
if (selectedSkill) joinSession(data.id, data.name || selectedSkill.name, 'codex');
|
|
1145
|
+
else refreshSessionsNow();
|
|
1146
|
+
} catch (e) {
|
|
1147
|
+
alert('Failed to create session: ' + e.message);
|
|
1148
|
+
if (selectedSkill) await showToolModal(selectedSkill);
|
|
1149
|
+
}
|
|
1129
1150
|
}
|
package/lib/web/codex.js
CHANGED
|
@@ -438,8 +438,11 @@
|
|
|
438
438
|
}, 300);
|
|
439
439
|
}
|
|
440
440
|
|
|
441
|
-
function applyCodexState(state = {}) {
|
|
441
|
+
function applyCodexState(state = {}, options = {}) {
|
|
442
442
|
codexState = { ...codexState, ...state };
|
|
443
|
+
if (typeof syncComposerSendState === 'function') {
|
|
444
|
+
syncComposerSendState({ acknowledgeProviderState: Boolean(options.providerStateReceived) });
|
|
445
|
+
}
|
|
443
446
|
const permission = document.getElementById('codex-permission-select');
|
|
444
447
|
if (permission) {
|
|
445
448
|
const defaultOption = permission.querySelector('option[value="default"]');
|
|
@@ -621,7 +624,7 @@
|
|
|
621
624
|
}
|
|
622
625
|
else if (event.type === 'permission-request' && event.request) { codexPendingPermissions = [...codexPendingPermissions.filter(item => item.id !== event.request.id), event.request]; }
|
|
623
626
|
else if (event.type === 'permission-updated' && event.request) codexPendingPermissions = codexPendingPermissions.map(item => item.id === event.request.id ? event.request : item);
|
|
624
|
-
if (event.state) applyCodexState(event.state); else renderCodexChat();
|
|
627
|
+
if (event.state) applyCodexState(event.state, { providerStateReceived: true }); else renderCodexChat();
|
|
625
628
|
}
|
|
626
629
|
|
|
627
630
|
function sendCodexSettings(settings) { if (currentSocket?.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-settings', settings })); }
|
package/lib/web/composer.js
CHANGED
|
@@ -8,6 +8,33 @@
|
|
|
8
8
|
let composerActionTooltipTimer = null;
|
|
9
9
|
let composerTouchGesture = null;
|
|
10
10
|
let suppressTouchFocusUntil = 0;
|
|
11
|
+
let composerSendPending = false;
|
|
12
|
+
|
|
13
|
+
function composerExecutionInProgress() {
|
|
14
|
+
if (composerSendPending) return true;
|
|
15
|
+
if (isClaudeSession()) return claudeStatus === 'thinking';
|
|
16
|
+
if (isCodexSession()) return !codexReadyForInput();
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function syncComposerSendState({ acknowledgeProviderState = false } = {}) {
|
|
21
|
+
if (acknowledgeProviderState) composerSendPending = false;
|
|
22
|
+
const sendButton = document.getElementById('send-btn');
|
|
23
|
+
const executing = composerExecutionInProgress();
|
|
24
|
+
sendButton.disabled = executing;
|
|
25
|
+
sendButton.title = executing ? 'Wait for the current run to finish' : 'Send';
|
|
26
|
+
sendButton.setAttribute('aria-label', executing ? 'Send disabled while running' : 'Send');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function markComposerSendPending() {
|
|
30
|
+
composerSendPending = true;
|
|
31
|
+
syncComposerSendState();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function resetComposerSendState() {
|
|
35
|
+
composerSendPending = false;
|
|
36
|
+
syncComposerSendState();
|
|
37
|
+
}
|
|
11
38
|
|
|
12
39
|
function composerActionControl(target) {
|
|
13
40
|
const control = target instanceof Element ? target.closest('button, label') : null;
|
|
@@ -395,6 +422,7 @@
|
|
|
395
422
|
});
|
|
396
423
|
|
|
397
424
|
function performSend() {
|
|
425
|
+
if (document.getElementById('send-btn').disabled) return;
|
|
398
426
|
const val = inputEl.value;
|
|
399
427
|
const readyImageAttachments = selectedImageAttachments.filter(item => !item.uploading);
|
|
400
428
|
const readyFileAttachments = selectedFileAttachments.filter(item => !item.uploading);
|
|
@@ -410,6 +438,7 @@
|
|
|
410
438
|
attachmentIds: readyImageAttachments.map(item => item.id),
|
|
411
439
|
...(readyFileAttachments.length ? { fileAttachmentIds: readyFileAttachments.map(item => item.id) } : {})
|
|
412
440
|
}));
|
|
441
|
+
markComposerSendPending();
|
|
413
442
|
}
|
|
414
443
|
inputEl.value = '';
|
|
415
444
|
inputEl.style.height = '38px';
|
|
@@ -428,6 +457,7 @@
|
|
|
428
457
|
...(readyFileAttachments.length ? { fileAttachmentIds: readyFileAttachments.map(item => item.id) } : {}),
|
|
429
458
|
skills: selectedCodexSkill ? [{ name: selectedCodexSkill.name, path: selectedCodexSkill.path }] : []
|
|
430
459
|
}));
|
|
460
|
+
markComposerSendPending();
|
|
431
461
|
}
|
|
432
462
|
inputEl.value = '';
|
|
433
463
|
inputEl.style.height = '38px';
|