ai_co_discuss 0.3.71 → 0.3.72
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/backend/free_room.py +81 -9
- package/backend/schema.sql +1675 -1645
- package/backend/server.py +2643 -2638
- package/docs/runner.md +12 -1
- package/package.json +1 -1
- package/runner/adapters/antigravity_cli.mjs +37 -7
- package/runner/adapters/api.mjs +25 -4
- package/runner/adapters/api_files.mjs +36 -19
- package/runner/adapters/claude.mjs +448 -385
- package/runner/adapters/cli.mjs +175 -155
- package/runner/adapters/codex_app.mjs +19 -3
- package/runner/adapters/codex_bridge.mjs +123 -0
- package/runner/adapters/codex_cli.mjs +47 -10
- package/runner/adapters/index.mjs +34 -2
- package/runner/adapters/profiles.mjs +5 -3
- package/runner/device_auth.mjs +39 -21
- package/runner/discussion_launch.mjs +83 -21
- package/runner/discussion_local.mjs +2 -0
- package/runner/discussion_runner.mjs +34 -1
- package/runner/expert_prompts.mjs +21 -11
- package/runner/free_runner.mjs +73 -14
- package/runner/project_files.mjs +57 -5
- package/runner/relay_channel.mjs +4 -3
- package/runner/remote_launch.mjs +268 -268
- package/runner/remote_policy.mjs +2 -1
- package/web/app.js +4955 -4862
- package/web/backup.js +1 -1
- package/web/modern.css +1 -1
- package/web/modern.js +9793 -9187
package/docs/runner.md
CHANGED
|
@@ -47,6 +47,16 @@ codiscuss setup
|
|
|
47
47
|
|
|
48
48
|
其他设备通过已配对电脑的加密连接读取同一目录。文件内容最多预览前 128 KB,支持 UTF-8 和带 BOM 的 UTF-16;HTML 展示内联样式与内嵌图片。文件访问限于房间项目目录,目录外的链接灰显。
|
|
49
49
|
|
|
50
|
+
### 多个项目目录
|
|
51
|
+
|
|
52
|
+
一个房间可以有一个主项目目录和最多 8 个其他项目目录。新建房间选好项目目录后,点下方“添加其他项目目录”;建房后在“成员与设置 → 协作设置”的“项目目录”一行增删,保存后从各成员下一次执行起生效,会话不中断。主项目目录设置后不能更改;建房时没选的,可在同一行补设一次,已在房间的成员保留原会话、按自己的项目权限读写它。
|
|
53
|
+
|
|
54
|
+
- 成员按自己的项目权限读写这些目录:只读成员全部只读,可写成员全部可写。
|
|
55
|
+
- “文件”页上方可切换目录;输入框里 `@` 搜索文件时会一起搜其他目录,选中后按完整路径引用。
|
|
56
|
+
- Claude Code、Codex CLI、agy 和 API 成员由程序限制读写;Codex App、Antigravity App 与在原窗口工作的手动成员只在任务里写明,实际权限以软件自身设置为准。
|
|
57
|
+
|
|
58
|
+
工作目录不在房间任何项目目录里的成员(例如手动接入时它的 Claude 会话属于别的文件夹,或在启动配置里给它设了别的工作目录)也可以加入:它在自己的目录工作,对房间的目录只读,它自己目录里的改动不参与冲突提示。它加入或换目录时,网页右下角会提示一次,成员卡上也会标出它的工作目录。Codex CLI、API 成员,以及只读档的 Claude Code、agy 由程序保证改不了房间目录;其他情况(App、手动成员、可写档的 Claude Code 与 agy、自动与全放开)只在任务里要求,成员卡会注明“程序拦不住”。
|
|
59
|
+
|
|
50
60
|
### 从 AI 对话发起
|
|
51
61
|
|
|
52
62
|
在要担任主持人的本地 AI 对话中调用 `discuss-host` 和 `discuss-participant`,说明站点、议题、参与者与上限。主持技能沿用本机软件配置建房,当前会话继续主持。agy 不能在自己的对话中主持全自动讨论。
|
|
@@ -74,7 +84,8 @@ codiscuss local connect https://你的站点
|
|
|
74
84
|
|
|
75
85
|
之后:
|
|
76
86
|
- 设备在浏览器里保存一把不可导出的设备密钥,之后打开网站直接可用。网站只认电脑签过名的设备名单,没有能被盗用的账号密码。
|
|
77
|
-
- 每台设备授权 30
|
|
87
|
+
- 每台设备授权 30 天。到期前或到期后 30 天内打开网页,用通行密钥确认一次即续期 30 天,不用回到电脑前;超过这段时间须重新配对。勾选“临时设备”的只授权 1 天,用于借来的电脑,不能续期。
|
|
88
|
+
- 新设备的浏览器不支持通行密钥时(例如没有 Google Play 服务的安卓平板),在电脑上添加设备前勾选“允许不用通行密钥”再生成二维码。这样的设备不需要每日确认,也不能续期,到期须回电脑前重新配对;它在解锁状态下被别人拿到就能操作电脑,只在确实需要时勾选。
|
|
78
89
|
- 新设备只能在电脑前添加,已配对设备不能远程添加。设备丢失时,在其他已配对设备或电脑上解绑它;只有一台外出设备时,回到电脑前解绑并重新配对。
|
|
79
90
|
- 每台设备的记录按钮(时钟图标)列出电脑为它记下的配对、解绑、每日确认和远程操作(不含内容);已解绑设备的记录汇总在列表末尾。看到不是你做的操作,立即解绑对应设备。
|
|
80
91
|
- 清除浏览器数据会删掉设备密钥,需要回到电脑前重新配对。
|
package/package.json
CHANGED
|
@@ -74,19 +74,28 @@ export function antigravity_project(config, root = join(homedir(), '.gemini', 'c
|
|
|
74
74
|
return matching[0];
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* Resolve only the user's selected folders, plus the room's other project directories (`task.extra_directories`); do
|
|
79
|
+
* not inherit another project's grants or settings.
|
|
80
|
+
*/
|
|
78
81
|
function agy_resources(config, task, root) {
|
|
79
|
-
|
|
80
|
-
|
|
82
|
+
const extras = (task.extra_directories || []).map(item => ({ folder: { folderUri: pathToFileURL(item.path).href } }));
|
|
83
|
+
if (config.project_id || config.project_name) return [...antigravity_project(config, root).projectResources?.resources || [], ...extras];
|
|
84
|
+
return [{ folder: { folderUri: pathToFileURL(launch_directory(config, task)).href } }, ...extras];
|
|
81
85
|
}
|
|
82
86
|
|
|
83
|
-
/**
|
|
87
|
+
/**
|
|
88
|
+
* Build native deny rules for protected directories, with separate writable answer storage. The room's project
|
|
89
|
+
* directories this member may only read are protected whatever its own access.
|
|
90
|
+
*/
|
|
84
91
|
function agy_project_grants(config, task, resources, user_home) {
|
|
85
92
|
const folders = resources.map(resource => resource.gitFolder?.folderUri || resource.folder?.folderUri)
|
|
86
93
|
.filter(uri => uri?.startsWith('file:')).map(uri => fileURLToPath(uri));
|
|
87
|
-
const
|
|
94
|
+
const read_only = (task.extra_directories || []).filter(item => item.access === 'read').map(item => item.path);
|
|
95
|
+
const projects = [...new Set([project_directory(config), ...folders].filter(directory => directory && !same_path(directory, task.draft_directory)
|
|
96
|
+
&& !read_only.some(path => same_path(path, directory))))];
|
|
88
97
|
const protected_paths = [task.directory, ...task.skill_directories, join(user_home, '.gemini'),
|
|
89
|
-
...(config.project_access === 'read' ? projects : [])];
|
|
98
|
+
...(config.project_access === 'read' ? projects : []), ...read_only];
|
|
90
99
|
if (protected_paths.some(directory => within_directory(directory, task.draft_directory))) {
|
|
91
100
|
throw new Error('agy 草稿目录不能位于只读项目、运行目录、技能或 .gemini 配置目录中,未交付讨论任务');
|
|
92
101
|
}
|
|
@@ -147,7 +156,7 @@ export function remove_agy_projects(directory, user_home = homedir()) {
|
|
|
147
156
|
function invocation(config, binding, task, user_home) {
|
|
148
157
|
const project = config.session_id ? null : prepare_agy_project(config, binding, task, user_home);
|
|
149
158
|
const args = ['--output-format', 'stream-json', '--print-timeout', '0', '--add-dir', task.directory,
|
|
150
|
-
'--add-dir', task.draft_directory, ...task.skill_directories.flatMap(directory => ['--add-dir', directory])];
|
|
159
|
+
'--add-dir', task.draft_directory, ...[...task.skill_directories, ...(task.extra_directories || []).map(item => item.path)].flatMap(directory => ['--add-dir', directory])];
|
|
151
160
|
// Headless agy refuses `--mode auto` ("not available for this account", 1.4.10–1.4.11) though its interactive window
|
|
152
161
|
// offers it; 全放开 uses the flag its own denial message suggests on top of the open project grants.
|
|
153
162
|
args.push('--mode', 'accept-edits', ...(config.project_access === 'full' ? ['--dangerously-skip-permissions'] : []),
|
|
@@ -211,8 +220,29 @@ const events = {
|
|
|
211
220
|
responses.delete(step.step_index);
|
|
212
221
|
return text;
|
|
213
222
|
},
|
|
223
|
+
detail: agy_detail,
|
|
214
224
|
};
|
|
215
225
|
|
|
226
|
+
/**
|
|
227
|
+
* One agy stream event as execution-detail entries keyed by step: a response grows by its text deltas, a tool step
|
|
228
|
+
* shows its main parameter and, once finished, its output or error.
|
|
229
|
+
*/
|
|
230
|
+
function agy_detail(event) {
|
|
231
|
+
if (event.event === 'result' && event.result?.status === 'ERROR') return [{ kind: 'error', body: String(event.result.error || '本轮失败') }];
|
|
232
|
+
const step = event.step_update;
|
|
233
|
+
if (!step) return [];
|
|
234
|
+
const id = `step-${step.step_index}`;
|
|
235
|
+
if (step.step_type === 'agent_response') return step.text_delta ? [{ id, kind: 'message', append: step.text_delta }] : [];
|
|
236
|
+
if (step.step_type !== 'tool') return [];
|
|
237
|
+
const parameters = step.tool_info?.parameters || {};
|
|
238
|
+
const subject = parameters.CommandLine ?? parameters.Path ?? parameters.AbsolutePath ?? parameters.TargetFile ?? Object.values(parameters).find(value => typeof value === 'string');
|
|
239
|
+
const finished = ['DONE', 'ERROR'].includes(step.state);
|
|
240
|
+
return [{ id, kind: step.tool_name === 'run_command' ? 'command' : EDIT_TOOLS.test(step.tool_name || '') ? 'edit' : 'tool',
|
|
241
|
+
title: step.tool_name === 'run_command' ? String(subject ?? '') : `${step.tool_name} ${subject ?? ''}`.trim(),
|
|
242
|
+
state: step.state === 'ERROR' ? 'failed' : finished ? 'done' : 'running',
|
|
243
|
+
body: finished ? String(step.tool_info?.error?.message ?? step.tool_info?.output ?? '') : undefined }];
|
|
244
|
+
}
|
|
245
|
+
|
|
216
246
|
/** Where agy keeps one conversation database, or '' when this machine has none by that ID. */
|
|
217
247
|
export function session_path(session_id, env = process.env, home = homedir()) {
|
|
218
248
|
const file = join(home, '.gemini', 'antigravity-cli', 'conversations', `${session_id}.db`);
|
package/runner/adapters/api.mjs
CHANGED
|
@@ -213,8 +213,10 @@ function project_text(project) {
|
|
|
213
213
|
const attachments = '消息附件只显示名称、大小与 ID,内容读不到';
|
|
214
214
|
if (!project) return `- 本地文件和消息附件(只显示名称、大小与 ID)的内容你都读不到:以其他成员贴出的为准,不凭文件名猜测;回答涉及时说明你没有读取,缺信息就写明缺什么。`;
|
|
215
215
|
const tools = project.writable ? 'read、glob、grep、write、edit' : 'read、glob、grep';
|
|
216
|
+
const extras = project.roots.slice(1).map(item => `${item.root}(${item.writable ? '可读写' : '只读'})`).join('、');
|
|
216
217
|
return `- 可用 ${tools} 工具${project.writable ? '读写' : '只读'}本房间的项目目录,路径相对项目目录,不能越出;密钥文件、版本库元数据和接入程序的私有文件会被拒绝。${project.writable
|
|
217
|
-
? '修改已有文件前先 read,局部修改优先用 edit;没有命令行,不能运行测试或构建,需要验证时在回答里说明,交给能运行命令的成员。技能里的草稿文件与 --body-file 不适用:回答正文直接放进 submit_chat,不要用 write 写草稿。' : '项目只读,不能修改文件。'}
|
|
218
|
+
? '修改已有文件前先 read,局部修改优先用 edit;没有命令行,不能运行测试或构建,需要验证时在回答里说明,交给能运行命令的成员。技能里的草稿文件与 --body-file 不适用:回答正文直接放进 submit_chat,不要用 write 写草稿。' : '项目只读,不能修改文件。'}${extras ? `
|
|
219
|
+
- 本房间另有项目目录,用绝对路径访问:${extras}。` : ''}
|
|
218
220
|
- ${attachments}:以其他成员贴出的为准,不凭文件名猜测;回答涉及时说明你没有读取,缺信息就写明缺什么。`;
|
|
219
221
|
}
|
|
220
222
|
|
|
@@ -264,6 +266,19 @@ function append_note(profile, history, text) {
|
|
|
264
266
|
else history.push({ role: 'user', content: text });
|
|
265
267
|
}
|
|
266
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Add user text before the next request. The last message is the user's at that point (the prompt, tool results or a
|
|
271
|
+
* reminder); protocols that keep one user message per turn take it into that message, a string prompt becoming a block.
|
|
272
|
+
*/
|
|
273
|
+
function add_user_text(profile, history, text) {
|
|
274
|
+
const last = history.at(-1);
|
|
275
|
+
if (profile.api_protocol === 'anthropic' && last?.role === 'user') {
|
|
276
|
+
if (typeof last.content === 'string') last.content = [{ type: 'text', text: last.content }];
|
|
277
|
+
last.content.push({ type: 'text', text });
|
|
278
|
+
} else if (profile.api_protocol === 'gemini' && last?.role === 'user') last.parts.push({ text });
|
|
279
|
+
else history.push(profile.api_protocol === 'gemini' ? { role: 'user', parts: [{ text }] } : { role: 'user', content: text });
|
|
280
|
+
}
|
|
281
|
+
|
|
267
282
|
/** Plain text of the model's latest reply, so a stop without a write can show its stated reason. */
|
|
268
283
|
function last_reply_text(history, limit = REPLY_EXCERPT_CHARS) {
|
|
269
284
|
const item = history.at(-1) || {};
|
|
@@ -297,6 +312,8 @@ async function run_participant(profile, key, context) {
|
|
|
297
312
|
let used_tools = false;
|
|
298
313
|
const turns = max_requests(context);
|
|
299
314
|
for (let index = 0; !turns || index < turns; index++) {
|
|
315
|
+
// The owner's interjections join the conversation before the next model request, inside the same turn.
|
|
316
|
+
for (const text of (context.interjections || []).splice(0)) add_user_text(profile, state.history, `用户插话:${text}`);
|
|
300
317
|
context.on_activity?.('thinking');
|
|
301
318
|
const calls = await generate_with_retry(profile, key, state, context.signal);
|
|
302
319
|
if (!calls.length) {
|
|
@@ -337,7 +354,9 @@ export async function api_delivery(profile, binding, task) {
|
|
|
337
354
|
const controller = new AbortController();
|
|
338
355
|
const child = new EventEmitter();
|
|
339
356
|
child.kill = () => { controller.abort(); return true; };
|
|
340
|
-
const
|
|
357
|
+
const interjections = [];
|
|
358
|
+
let running = true;
|
|
359
|
+
const context = { ...task.api_context, participant: task.participant, directory: task.directory, interjections,
|
|
341
360
|
task_id: task.id, signal: controller.signal, on_activity: task.on_activity, on_note: task.on_note, on_edits: task.on_edits,
|
|
342
361
|
expert_prompt: expert_prompt(profile),
|
|
343
362
|
// The service home holds API keys and the task directory holds room credentials; neither is a project file.
|
|
@@ -351,9 +370,11 @@ export async function api_delivery(profile, binding, task) {
|
|
|
351
370
|
await run_participant(profile, key, context);
|
|
352
371
|
} catch (error) {
|
|
353
372
|
if (!controller.signal.aborted) task.on_failure?.(error.message.replaceAll(key, '[已隐藏]'));
|
|
354
|
-
} finally { child.emit('close', 0); task.on_exit?.(); }
|
|
373
|
+
} finally { running = false; child.emit('close', 0); task.on_exit?.(); }
|
|
355
374
|
});
|
|
356
|
-
|
|
375
|
+
// Taken at the next model request; the request already under way is not cut short.
|
|
376
|
+
const steer = text => { if (!running) return false; interjections.push(text); return true; };
|
|
377
|
+
return { status: 'accepted', session_id, capabilities: API_CALL, steer };
|
|
357
378
|
}
|
|
358
379
|
|
|
359
380
|
// The call is the runner's own, so it can be aborted and its tool steps are seen.
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Ported from the read / write / edit (packages/fs/tool-fs) and glob / grep (packages/fs/tool-fs-search) tools of
|
|
5
5
|
* DeepSeek Harness, https://github.com/deepseek-ai/deepseek-harness at ce7073d, MIT License, Copyright (c) 2026 DeepSeek:
|
|
6
6
|
* the same tool names, parameters, descriptions, result formats, limits and read-before-modify rule, rebuilt on Node
|
|
7
|
-
* built-ins. Differences: every path stays inside the room's project
|
|
7
|
+
* built-ins. Differences: every path stays inside the room's project directories (the main one, where relative paths
|
|
8
|
+
* resolve, and the extra ones by absolute path, each read-only or writable); grep takes JavaScript regular
|
|
8
9
|
* expressions and reads the files Git does not ignore, where DSH runs ripgrep; secrets, version-control metadata and the
|
|
9
10
|
* runner's private files are refused; a result over the cap is cut instead of saved to a spill file.
|
|
10
11
|
*/
|
|
@@ -40,7 +41,7 @@ function tool(name, description, properties, required) {
|
|
|
40
41
|
|
|
41
42
|
const DEFINITIONS = {
|
|
42
43
|
read: tool('read', 'Read a UTF-8 text file and return line-numbered content.', {
|
|
43
|
-
file_path: { ...TEXT, description: 'Path to read, relative to the project directory.' },
|
|
44
|
+
file_path: { ...TEXT, description: 'Path to read, relative to the project directory, or absolute inside another listed project directory.' },
|
|
44
45
|
offset: { ...NUMBER, description: '1-based first line to return. Defaults to 1.' },
|
|
45
46
|
limit: { ...NUMBER, maximum: READ_LIMIT, description: `Maximum number of lines to return. Defaults to ${READ_LIMIT}.` },
|
|
46
47
|
}, ['file_path']),
|
|
@@ -48,20 +49,20 @@ const DEFINITIONS = {
|
|
|
48
49
|
+ `Returns up to ${GLOB_MAX_RESULTS} paths in modification-time order; a larger result keeps the first paths.`, {
|
|
49
50
|
pattern: { ...TEXT, description: 'Glob pattern to match file paths against (e.g. "**/*.ts", "src/**/*.test.js"). '
|
|
50
51
|
+ 'A pattern with no "/" matches the basename at any depth, so "*" and "*.ts" both search the whole tree; include a separator to anchor the depth.' },
|
|
51
|
-
path: { ...TEXT, description: 'Directory to search in. Defaults to the project directory; a relative path resolves against it.' },
|
|
52
|
+
path: { ...TEXT, description: 'Directory to search in. Defaults to the project directory; a relative path resolves against it, an absolute one may name another listed project directory.' },
|
|
52
53
|
}, ['pattern']),
|
|
53
54
|
grep: tool('grep', 'Search file contents with a JavaScript regular expression. Returns matching lines with line numbers, grouped by file. '
|
|
54
55
|
+ `Returns up to ${GREP_MAX_MATCHES} matches.`, {
|
|
55
56
|
pattern: { ...TEXT, description: 'Regular expression to search for (JavaScript syntax).' },
|
|
56
|
-
path: { ...TEXT, description: 'File or directory to search. Defaults to the project directory; a relative path resolves against it.' },
|
|
57
|
+
path: { ...TEXT, description: 'File or directory to search. Defaults to the project directory; a relative path resolves against it, an absolute one may name another listed project directory.' },
|
|
57
58
|
include: { ...TEXT, description: 'One glob filter for which files to search (e.g. "*.ts", "*.{js,jsx}"). Not a list; negation is not supported.' },
|
|
58
59
|
}, ['pattern']),
|
|
59
60
|
write: tool('write', 'Create or fully replace a UTF-8 text file.', {
|
|
60
|
-
file_path: { ...TEXT, description: 'Path to write, relative to the project directory. Provide `file_path` before `content` in the arguments.' },
|
|
61
|
+
file_path: { ...TEXT, description: 'Path to write, relative to the project directory, or absolute inside another listed project directory. Provide `file_path` before `content` in the arguments.' },
|
|
61
62
|
content: { ...TEXT, description: 'Full UTF-8 text content to write.' },
|
|
62
63
|
}, ['file_path', 'content']),
|
|
63
64
|
edit: tool('edit', 'Edit an existing UTF-8 text file by replacing literal text.', {
|
|
64
|
-
file_path: { ...TEXT, description: 'Path to edit, relative to the project directory. Provide `file_path` before `old_string` and `new_string` in the arguments.' },
|
|
65
|
+
file_path: { ...TEXT, description: 'Path to edit, relative to the project directory, or absolute inside another listed project directory. Provide `file_path` before `old_string` and `new_string` in the arguments.' },
|
|
65
66
|
old_string: { ...TEXT, description: 'Literal text to replace.' },
|
|
66
67
|
new_string: { ...TEXT, description: 'Literal replacement text. Use an empty string to delete the match.' },
|
|
67
68
|
replace_all: { type: 'boolean', description: 'Replace all matches. Defaults to false; when false, old_string must appear exactly once.' },
|
|
@@ -84,39 +85,53 @@ function real_path(path) {
|
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
/**
|
|
87
|
-
* The project an API member may work in, or null when the room names none.
|
|
88
|
-
*
|
|
88
|
+
* The project an API member may work in, or null when the room names none. Without its own directory (it joined before
|
|
89
|
+
* the room had a project), the first of the room's directories is where relative paths resolve.
|
|
90
|
+
* @param {{cwd?: string, project_access?: string, extra_directories?: {path: string, access: string}[]}} options - the
|
|
91
|
+
* member's project directory, its access, and the room's other directories with its access to each.
|
|
89
92
|
* @param {string[]} private_paths - runner directories holding credentials, never readable through these tools.
|
|
90
|
-
* @returns {{root: string,
|
|
93
|
+
* @returns {{root: string, roots: {root: string, writable: boolean}[], writable: boolean, private_paths: string[],
|
|
94
|
+
* observed: Map<string, string>} | null} `root` is the main directory; `writable` is whether any directory is.
|
|
91
95
|
*/
|
|
92
|
-
export function open_project({ cwd, project_access }, private_paths = []) {
|
|
93
|
-
|
|
94
|
-
|
|
96
|
+
export function open_project({ cwd, project_access, extra_directories = [] }, private_paths = []) {
|
|
97
|
+
const roots = cwd ? [{ root: realpathSync(cwd), writable: project_access === 'write' }] : [];
|
|
98
|
+
for (const item of extra_directories) {
|
|
99
|
+
// A folder moved away since it was added is left out rather than failing the whole execution.
|
|
100
|
+
try { roots.push({ root: realpathSync(item.path), writable: project_access === 'write' && item.access === 'write' }); } catch { /* Gone. */ }
|
|
101
|
+
}
|
|
102
|
+
if (!roots.length) return null;
|
|
103
|
+
return { root: roots[0].root, roots, writable: roots.some(item => item.writable), observed: new Map(),
|
|
95
104
|
private_paths: private_paths.filter(Boolean).map(path => real_path(resolve(path))) };
|
|
96
105
|
}
|
|
97
106
|
|
|
107
|
+
/** The project directory holding a real path, or undefined outside all of them. */
|
|
108
|
+
function owner(project, path) {
|
|
109
|
+
return project.roots.find(item => inside(item.root, path));
|
|
110
|
+
}
|
|
111
|
+
|
|
98
112
|
/** The tools this member gets for its project; none without a project. */
|
|
99
113
|
export function file_tools(project) {
|
|
100
114
|
if (!project) return [];
|
|
101
115
|
return [...READ_TOOLS, ...(project.writable ? WRITE_TOOLS : [])].map(name => DEFINITIONS[name]);
|
|
102
116
|
}
|
|
103
117
|
|
|
104
|
-
/** The
|
|
118
|
+
/** The path shown to the model, with forward slashes: relative in the main directory, absolute in the others. */
|
|
105
119
|
function display(project, path) {
|
|
120
|
+
if (!inside(project.root, path)) return path.split(sep).join('/');
|
|
106
121
|
return relative(project.root, path).split(sep).join('/') || '.';
|
|
107
122
|
}
|
|
108
123
|
|
|
109
124
|
/** Why a path inside the project is still refused, or '' when it may be used. */
|
|
110
125
|
function refusal(project, path) {
|
|
111
|
-
if (relative(project.root, path).split(sep).some(part => VCS_DIRECTORIES.includes(part.toLowerCase()))) return 'version control metadata';
|
|
126
|
+
if (relative(owner(project, path)?.root ?? project.root, path).split(sep).some(part => VCS_DIRECTORIES.includes(part.toLowerCase()))) return 'version control metadata';
|
|
112
127
|
const name = basename(path);
|
|
113
128
|
if (!SECRET_EXAMPLES.test(name) && SECRET_NAMES.some(rule => rule.test(name))) return 'a secret file';
|
|
114
129
|
if (project.private_paths.some(root => inside(root, path))) return "the runner's private files";
|
|
115
130
|
return '';
|
|
116
131
|
}
|
|
117
132
|
|
|
118
|
-
/** Resolve a requested path to a real path inside the project, refusing protected files. */
|
|
119
|
-
function target(project, requested, exists = true) {
|
|
133
|
+
/** Resolve a requested path to a real path inside the project, refusing protected files and, to modify, read-only directories. */
|
|
134
|
+
function target(project, requested, exists = true, modify = false) {
|
|
120
135
|
if (typeof requested !== 'string' || !requested.trim()) throw new Error('file_path must be a non-empty string');
|
|
121
136
|
const absolute = resolve(project.root, requested);
|
|
122
137
|
let path;
|
|
@@ -125,7 +140,9 @@ function target(project, requested, exists = true) {
|
|
|
125
140
|
if (error.code === 'ENOENT') throw new Error(`cannot resolve "${requested}": not found`);
|
|
126
141
|
throw error;
|
|
127
142
|
}
|
|
128
|
-
|
|
143
|
+
const root = owner(project, path);
|
|
144
|
+
if (!root) throw new Error(`cannot access "${requested}": outside the project directories`);
|
|
145
|
+
if (modify && !root.writable) throw new Error(`cannot modify "${requested}": its directory is read-only for you`);
|
|
129
146
|
const reason = refusal(project, path);
|
|
130
147
|
if (reason) throw new Error(`cannot access "${requested}": ${reason} is not available to API members`);
|
|
131
148
|
return { path, shown: display(project, path) };
|
|
@@ -216,7 +233,7 @@ function atomic_write(file, content, info) {
|
|
|
216
233
|
|
|
217
234
|
function write(project, args) {
|
|
218
235
|
if (typeof args.content !== 'string') throw new Error('content must be a string');
|
|
219
|
-
const file = target(project, args.file_path, false);
|
|
236
|
+
const file = target(project, args.file_path, false, true);
|
|
220
237
|
let info = null;
|
|
221
238
|
try { info = statSync(file.path); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
222
239
|
if (info && !info.isFile()) throw new Error(`cannot write "${file.shown}": not a regular file`);
|
|
@@ -229,7 +246,7 @@ function edit(project, args) {
|
|
|
229
246
|
for (const field of ['old_string', 'new_string']) if (typeof args[field] !== 'string') throw new Error(`${field} must be a string`);
|
|
230
247
|
if (!args.old_string) throw new Error('old_string must be a non-empty string');
|
|
231
248
|
if (args.old_string === args.new_string) throw new Error('old_string and new_string must differ');
|
|
232
|
-
const file = target(project, args.file_path);
|
|
249
|
+
const file = target(project, args.file_path, true, true);
|
|
233
250
|
const info = regular_file(file, 'edit');
|
|
234
251
|
check_observed(project, file, info);
|
|
235
252
|
const raw = decode(file, readFileSync(file.path), 'edit');
|