@cloudcli-ai/cloudcli 1.35.1 → 1.36.1
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/README.de.md +17 -18
- package/README.ja.md +16 -17
- package/README.ko.md +17 -18
- package/README.md +19 -20
- package/README.ru.md +17 -18
- package/README.tr.md +17 -18
- package/README.zh-CN.md +17 -18
- package/README.zh-TW.md +17 -18
- package/dist/api-docs.html +8 -3
- package/dist/assets/{index-D1IdCTT6.js → index-Bz9vXV2m.js} +265 -260
- package/dist/assets/index-DLmhQ15L.css +32 -0
- package/dist/assets/{vendor-xterm-CJZjLICi.js → vendor-xterm-CS4rQ5Mr.js} +12 -12
- package/dist/index.html +3 -3
- package/dist-server/server/claude-sdk.js +62 -116
- package/dist-server/server/claude-sdk.js.map +1 -1
- package/dist-server/server/cli.js +1 -4
- package/dist-server/server/cli.js.map +1 -1
- package/dist-server/server/cursor-cli.js +12 -11
- package/dist-server/server/cursor-cli.js.map +1 -1
- package/dist-server/server/index.js +10 -141
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/modules/assets/assets.routes.js +89 -0
- package/dist-server/server/modules/assets/assets.routes.js.map +1 -0
- package/dist-server/server/modules/assets/index.js +4 -0
- package/dist-server/server/modules/assets/index.js.map +1 -0
- package/dist-server/server/modules/assets/services/image-assets.service.js +57 -0
- package/dist-server/server/modules/assets/services/image-assets.service.js.map +1 -0
- package/dist-server/server/modules/assets/tests/image-assets.service.test.js +36 -0
- package/dist-server/server/modules/assets/tests/image-assets.service.test.js.map +1 -0
- package/dist-server/server/modules/browser-use/browser-use.service.js +6 -3
- package/dist-server/server/modules/browser-use/browser-use.service.js.map +1 -1
- package/dist-server/server/modules/database/tests/sessions-provider-mapping.test.js +2 -2
- package/dist-server/server/modules/database/tests/sessions-provider-mapping.test.js.map +1 -1
- package/dist-server/server/modules/notifications/services/notification-orchestrator.service.js +0 -1
- package/dist-server/server/modules/notifications/services/notification-orchestrator.service.js.map +1 -1
- package/dist-server/server/modules/projects/services/project-clone.service.js +2 -1
- package/dist-server/server/modules/projects/services/project-clone.service.js.map +1 -1
- package/dist-server/server/modules/providers/list/claude/claude-models.provider.js +70 -1
- package/dist-server/server/modules/providers/list/claude/claude-models.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js +28 -0
- package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/codex/codex-models.provider.js +52 -11
- package/dist-server/server/modules/providers/list/codex/codex-models.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js +54 -1
- package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js +36 -1
- package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js +3 -17
- package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.js +7 -1
- package/dist-server/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js +49 -13
- package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/opencode/opencode-auth.provider.js +0 -1
- package/dist-server/server/modules/providers/list/opencode/opencode-auth.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js +145 -14
- package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js +24 -5
- package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js +10 -21
- package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js.map +1 -1
- package/dist-server/server/modules/providers/provider.registry.js +0 -2
- package/dist-server/server/modules/providers/provider.registry.js.map +1 -1
- package/dist-server/server/modules/providers/provider.routes.js +0 -1
- package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
- package/dist-server/server/modules/providers/services/provider-capabilities.service.js +11 -13
- package/dist-server/server/modules/providers/services/provider-capabilities.service.js.map +1 -1
- package/dist-server/server/modules/providers/services/provider-models.service.js +2 -2
- package/dist-server/server/modules/providers/services/provider-models.service.js.map +1 -1
- package/dist-server/server/modules/providers/services/session-conversations-search.service.js +3 -77
- package/dist-server/server/modules/providers/services/session-conversations-search.service.js.map +1 -1
- package/dist-server/server/modules/providers/services/session-synchronizer.service.js +0 -1
- package/dist-server/server/modules/providers/services/session-synchronizer.service.js.map +1 -1
- package/dist-server/server/modules/providers/services/sessions-watcher.service.js +0 -13
- package/dist-server/server/modules/providers/services/sessions-watcher.service.js.map +1 -1
- package/dist-server/server/modules/providers/tests/codex-sessions.test.js +96 -0
- package/dist-server/server/modules/providers/tests/codex-sessions.test.js.map +1 -0
- package/dist-server/server/modules/providers/tests/mcp.test.js +3 -29
- package/dist-server/server/modules/providers/tests/mcp.test.js.map +1 -1
- package/dist-server/server/modules/providers/tests/opencode-models.test.js +65 -1
- package/dist-server/server/modules/providers/tests/opencode-models.test.js.map +1 -1
- package/dist-server/server/modules/providers/tests/opencode-sessions.test.js +117 -0
- package/dist-server/server/modules/providers/tests/opencode-sessions.test.js.map +1 -1
- package/dist-server/server/modules/providers/tests/provider-image-history.test.js +147 -0
- package/dist-server/server/modules/providers/tests/provider-image-history.test.js.map +1 -0
- package/dist-server/server/modules/providers/tests/provider-models.service.test.js +8 -8
- package/dist-server/server/modules/providers/tests/skills.test.js +3 -26
- package/dist-server/server/modules/providers/tests/skills.test.js.map +1 -1
- package/dist-server/server/modules/websocket/services/chat-run-registry.service.js +14 -0
- package/dist-server/server/modules/websocket/services/chat-run-registry.service.js.map +1 -1
- package/dist-server/server/modules/websocket/services/chat-websocket.service.js +36 -2
- package/dist-server/server/modules/websocket/services/chat-websocket.service.js.map +1 -1
- package/dist-server/server/modules/websocket/services/shell-websocket.service.js +3 -12
- package/dist-server/server/modules/websocket/services/shell-websocket.service.js.map +1 -1
- package/dist-server/server/modules/websocket/tests/chat-image-filter.test.js +33 -0
- package/dist-server/server/modules/websocket/tests/chat-image-filter.test.js.map +1 -0
- package/dist-server/server/modules/websocket/tests/chat-run-registry.test.js +37 -4
- package/dist-server/server/modules/websocket/tests/chat-run-registry.test.js.map +1 -1
- package/dist-server/server/openai-codex.js +16 -9
- package/dist-server/server/openai-codex.js.map +1 -1
- package/dist-server/server/opencode-cli.js +62 -7
- package/dist-server/server/opencode-cli.js.map +1 -1
- package/dist-server/server/opencode-cli.test.js +103 -3
- package/dist-server/server/opencode-cli.test.js.map +1 -1
- package/dist-server/server/routes/agent.js +22 -21
- package/dist-server/server/routes/agent.js.map +1 -1
- package/dist-server/server/routes/commands.js +1 -2
- package/dist-server/server/routes/commands.js.map +1 -1
- package/dist-server/server/routes/git.js +179 -53
- package/dist-server/server/routes/git.js.map +1 -1
- package/dist-server/server/routes/git.test.js +80 -0
- package/dist-server/server/routes/git.test.js.map +1 -0
- package/dist-server/server/routes/taskmaster.js +3 -1
- package/dist-server/server/routes/taskmaster.js.map +1 -1
- package/dist-server/server/routes/user.js +2 -1
- package/dist-server/server/routes/user.js.map +1 -1
- package/dist-server/server/shared/image-attachments.js +270 -0
- package/dist-server/server/shared/image-attachments.js.map +1 -0
- package/dist-server/server/shared/tests/image-attachments.test.js +220 -0
- package/dist-server/server/shared/tests/image-attachments.test.js.map +1 -0
- package/dist-server/server/shared/utils.js +43 -0
- package/dist-server/server/shared/utils.js.map +1 -1
- package/dist-server/server/utils/gitConfig.js +2 -1
- package/dist-server/server/utils/gitConfig.js.map +1 -1
- package/dist-server/server/utils/plugin-process-manager.js +2 -1
- package/dist-server/server/utils/plugin-process-manager.js.map +1 -1
- package/electron/launcher/launcher.js +1 -1
- package/package.json +3 -3
- package/public/api-docs.html +8 -3
- package/server/claude-sdk.js +69 -131
- package/server/cli.js +1 -4
- package/server/cursor-cli.js +13 -12
- package/server/index.js +11 -159
- package/server/modules/assets/assets.routes.ts +103 -0
- package/server/modules/assets/index.ts +3 -0
- package/server/modules/assets/services/image-assets.service.ts +82 -0
- package/server/modules/assets/tests/image-assets.service.test.ts +46 -0
- package/server/modules/browser-use/browser-use.service.ts +7 -3
- package/server/modules/database/tests/sessions-provider-mapping.test.ts +2 -2
- package/server/modules/notifications/services/notification-orchestrator.service.js +0 -1
- package/server/modules/projects/services/project-clone.service.ts +3 -1
- package/server/modules/providers/README.md +1 -7
- package/server/modules/providers/list/claude/claude-models.provider.ts +73 -1
- package/server/modules/providers/list/claude/claude-sessions.provider.ts +30 -0
- package/server/modules/providers/list/codex/codex-models.provider.ts +59 -11
- package/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts +60 -1
- package/server/modules/providers/list/codex/codex-sessions.provider.ts +41 -1
- package/server/modules/providers/list/cursor/cursor-models.provider.ts +3 -17
- package/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.ts +7 -1
- package/server/modules/providers/list/cursor/cursor-sessions.provider.ts +56 -15
- package/server/modules/providers/list/opencode/opencode-auth.provider.ts +0 -1
- package/server/modules/providers/list/opencode/opencode-models.provider.ts +193 -18
- package/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts +25 -4
- package/server/modules/providers/list/opencode/opencode-sessions.provider.ts +10 -21
- package/server/modules/providers/provider.registry.ts +0 -2
- package/server/modules/providers/provider.routes.ts +0 -1
- package/server/modules/providers/services/provider-capabilities.service.ts +13 -13
- package/server/modules/providers/services/provider-models.service.ts +2 -2
- package/server/modules/providers/services/session-conversations-search.service.ts +3 -93
- package/server/modules/providers/services/session-synchronizer.service.ts +0 -1
- package/server/modules/providers/services/sessions-watcher.service.ts +0 -14
- package/server/modules/providers/tests/codex-sessions.test.ts +111 -0
- package/server/modules/providers/tests/mcp.test.ts +3 -34
- package/server/modules/providers/tests/opencode-models.test.ts +69 -0
- package/server/modules/providers/tests/opencode-sessions.test.ts +139 -0
- package/server/modules/providers/tests/provider-image-history.test.ts +180 -0
- package/server/modules/providers/tests/provider-models.service.test.ts +8 -8
- package/server/modules/providers/tests/skills.test.ts +3 -42
- package/server/modules/websocket/services/chat-run-registry.service.ts +16 -0
- package/server/modules/websocket/services/chat-websocket.service.ts +41 -2
- package/server/modules/websocket/services/shell-websocket.service.ts +1 -11
- package/server/modules/websocket/tests/chat-image-filter.test.ts +44 -0
- package/server/modules/websocket/tests/chat-run-registry.test.ts +42 -4
- package/server/openai-codex.js +18 -8
- package/server/opencode-cli.js +64 -7
- package/server/opencode-cli.test.js +112 -3
- package/server/routes/agent.js +22 -21
- package/server/routes/commands.js +1 -2
- package/server/routes/git.js +201 -60
- package/server/routes/git.test.js +106 -0
- package/server/routes/taskmaster.js +3 -1
- package/server/routes/user.js +2 -1
- package/server/shared/image-attachments.ts +341 -0
- package/server/shared/tests/image-attachments.test.ts +298 -0
- package/server/shared/types.ts +8 -1
- package/server/shared/utils.ts +45 -0
- package/server/utils/gitConfig.js +2 -1
- package/server/utils/plugin-process-manager.js +3 -1
- package/dist/assets/index-BvX30NW5.css +0 -32
- package/dist/icons/gemini-ai-icon.svg +0 -1
- package/dist-server/server/gemini-cli.js +0 -551
- package/dist-server/server/gemini-cli.js.map +0 -1
- package/dist-server/server/gemini-response-handler.js +0 -106
- package/dist-server/server/gemini-response-handler.js.map +0 -1
- package/dist-server/server/modules/providers/list/gemini/gemini-auth.provider.js +0 -254
- package/dist-server/server/modules/providers/list/gemini/gemini-auth.provider.js.map +0 -1
- package/dist-server/server/modules/providers/list/gemini/gemini-mcp.provider.js +0 -82
- package/dist-server/server/modules/providers/list/gemini/gemini-mcp.provider.js.map +0 -1
- package/dist-server/server/modules/providers/list/gemini/gemini-models.provider.js +0 -24
- package/dist-server/server/modules/providers/list/gemini/gemini-models.provider.js.map +0 -1
- package/dist-server/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.js +0 -312
- package/dist-server/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.js.map +0 -1
- package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js +0 -474
- package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js.map +0 -1
- package/dist-server/server/modules/providers/list/gemini/gemini-skills.provider.js +0 -40
- package/dist-server/server/modules/providers/list/gemini/gemini-skills.provider.js.map +0 -1
- package/dist-server/server/modules/providers/list/gemini/gemini.provider.js +0 -19
- package/dist-server/server/modules/providers/list/gemini/gemini.provider.js.map +0 -1
- package/dist-server/server/routes/gemini.js +0 -21
- package/dist-server/server/routes/gemini.js.map +0 -1
- package/dist-server/server/sessionManager.js +0 -194
- package/dist-server/server/sessionManager.js.map +0 -1
- package/server/gemini-cli.js +0 -638
- package/server/gemini-response-handler.js +0 -117
- package/server/modules/providers/list/gemini/gemini-auth.provider.ts +0 -307
- package/server/modules/providers/list/gemini/gemini-mcp.provider.ts +0 -110
- package/server/modules/providers/list/gemini/gemini-models.provider.ts +0 -39
- package/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.ts +0 -405
- package/server/modules/providers/list/gemini/gemini-sessions.provider.ts +0 -540
- package/server/modules/providers/list/gemini/gemini-skills.provider.ts +0 -44
- package/server/modules/providers/list/gemini/gemini.provider.ts +0 -27
- package/server/routes/gemini.js +0 -25
- package/server/sessionManager.js +0 -226
|
@@ -444,34 +444,22 @@ test('providerSkillsService lists opencode project and user compatibility skills
|
|
|
444
444
|
});
|
|
445
445
|
|
|
446
446
|
/**
|
|
447
|
-
* This test covers
|
|
447
|
+
* This test covers Cursor skill directory rules, including shared
|
|
448
448
|
* `.agents/skills` project support.
|
|
449
449
|
*/
|
|
450
|
-
test('providerSkillsService lists
|
|
450
|
+
test('providerSkillsService lists cursor skills from its configured directories', { concurrency: false }, async () => {
|
|
451
451
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-gc-'));
|
|
452
452
|
const workspacePath = path.join(tempRoot, 'workspace');
|
|
453
453
|
await fs.mkdir(workspacePath, { recursive: true });
|
|
454
454
|
|
|
455
455
|
const restoreHomeDir = patchHomeDir(tempRoot);
|
|
456
456
|
try {
|
|
457
|
-
await writeSkill(
|
|
458
|
-
path.join(tempRoot, '.gemini', 'skills'),
|
|
459
|
-
'gemini-user-dir',
|
|
460
|
-
'gemini-user',
|
|
461
|
-
'Gemini user skill',
|
|
462
|
-
);
|
|
463
457
|
await writeSkill(
|
|
464
458
|
path.join(tempRoot, '.agents', 'skills'),
|
|
465
459
|
'agents-user-dir',
|
|
466
460
|
'agents-user',
|
|
467
461
|
'Agents user skill',
|
|
468
462
|
);
|
|
469
|
-
await writeSkill(
|
|
470
|
-
path.join(workspacePath, '.gemini', 'skills'),
|
|
471
|
-
'gemini-project-dir',
|
|
472
|
-
'gemini-project',
|
|
473
|
-
'Gemini project skill',
|
|
474
|
-
);
|
|
475
463
|
await writeSkill(
|
|
476
464
|
path.join(workspacePath, '.agents', 'skills'),
|
|
477
465
|
'agents-project-dir',
|
|
@@ -491,14 +479,6 @@ test('providerSkillsService lists gemini and cursor skills from their configured
|
|
|
491
479
|
'Cursor user skill',
|
|
492
480
|
);
|
|
493
481
|
|
|
494
|
-
const geminiSkills = await providerSkillsService.listProviderSkills('gemini', { workspacePath });
|
|
495
|
-
const geminiByName = new Map(geminiSkills.map((skill) => [skill.name, skill]));
|
|
496
|
-
assert.equal(geminiByName.get('gemini-user')?.scope, 'user');
|
|
497
|
-
assert.equal(geminiByName.get('agents-user')?.scope, 'user');
|
|
498
|
-
assert.equal(geminiByName.get('gemini-project')?.scope, 'project');
|
|
499
|
-
assert.equal(geminiByName.get('agents-project')?.scope, 'project');
|
|
500
|
-
assert.equal(geminiByName.get('gemini-project')?.command, '/gemini-project');
|
|
501
|
-
|
|
502
482
|
const cursorSkills = await providerSkillsService.listProviderSkills('cursor', { workspacePath });
|
|
503
483
|
const cursorByName = new Map(cursorSkills.map((skill) => [skill.name, skill]));
|
|
504
484
|
assert.equal(cursorByName.get('agents-project')?.scope, 'project');
|
|
@@ -515,7 +495,7 @@ test('providerSkillsService lists gemini and cursor skills from their configured
|
|
|
515
495
|
* This test covers managed global skill creation for providers that own a
|
|
516
496
|
* writable user skill directory.
|
|
517
497
|
*/
|
|
518
|
-
test('providerSkillsService adds global skills for claude, codex,
|
|
498
|
+
test('providerSkillsService adds global skills for claude, codex, and cursor', { concurrency: false }, async () => {
|
|
519
499
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-create-'));
|
|
520
500
|
const restoreHomeDir = patchHomeDir(tempRoot);
|
|
521
501
|
|
|
@@ -618,22 +598,6 @@ test('providerSkillsService adds global skills for claude, codex, gemini, and cu
|
|
|
618
598
|
);
|
|
619
599
|
await assert.rejects(fs.stat(pendingBatchSkillPath), { code: 'ENOENT' });
|
|
620
600
|
|
|
621
|
-
const createdGeminiSkills = await providerSkillsService.addProviderSkills('gemini', {
|
|
622
|
-
entries: [
|
|
623
|
-
{
|
|
624
|
-
directoryName: 'gemini-global-dir',
|
|
625
|
-
content: '---\nname: gemini-global\ndescription: Gemini global skill\n---\n\nGemini body.\n',
|
|
626
|
-
},
|
|
627
|
-
],
|
|
628
|
-
});
|
|
629
|
-
const createdGeminiSkill = createdGeminiSkills[0];
|
|
630
|
-
assert.ok(createdGeminiSkill);
|
|
631
|
-
assert.equal(createdGeminiSkill.command, '/gemini-global');
|
|
632
|
-
assert.equal(
|
|
633
|
-
createdGeminiSkill.sourcePath.endsWith(path.join('.gemini', 'skills', 'gemini-global-dir', 'SKILL.md')),
|
|
634
|
-
true,
|
|
635
|
-
);
|
|
636
|
-
|
|
637
601
|
const createdCursorSkills = await providerSkillsService.addProviderSkills('cursor', {
|
|
638
602
|
entries: [
|
|
639
603
|
{
|
|
@@ -656,9 +620,6 @@ test('providerSkillsService adds global skills for claude, codex, gemini, and cu
|
|
|
656
620
|
const listedCodexSkills = await providerSkillsService.listProviderSkills('codex');
|
|
657
621
|
assert.equal(listedCodexSkills.some((skill) => skill.name === 'replacement'), true);
|
|
658
622
|
|
|
659
|
-
const listedGeminiSkills = await providerSkillsService.listProviderSkills('gemini');
|
|
660
|
-
assert.equal(listedGeminiSkills.some((skill) => skill.name === 'gemini-global'), true);
|
|
661
|
-
|
|
662
623
|
const listedCursorSkills = await providerSkillsService.listProviderSkills('cursor');
|
|
663
624
|
assert.equal(listedCursorSkills.some((skill) => skill.name === 'cursor-global'), true);
|
|
664
625
|
|
|
@@ -318,6 +318,22 @@ export const chatRunRegistry = {
|
|
|
318
318
|
run.writer.sendComplete(opts);
|
|
319
319
|
},
|
|
320
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Safety-net variant of `completeRun` scoped to one specific run: a no-op
|
|
323
|
+
* unless `run` is still the session's current, running run. A runtime
|
|
324
|
+
* promise can resolve after its own `complete` already streamed AND a new
|
|
325
|
+
* run has replaced it in the registry (a queued message sends within
|
|
326
|
+
* milliseconds of the previous turn ending) — the session-keyed
|
|
327
|
+
* `completeRun` would terminate that newer run.
|
|
328
|
+
*/
|
|
329
|
+
completeRunIfCurrent(run: ChatRun, opts: { exitCode: number; aborted?: boolean }): void {
|
|
330
|
+
if (runs.get(run.appSessionId) !== run || run.status !== 'running') {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
run.writer.sendComplete(opts);
|
|
335
|
+
},
|
|
336
|
+
|
|
321
337
|
/**
|
|
322
338
|
* Test-only escape hatch: clears every tracked run.
|
|
323
339
|
*/
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
1
3
|
import type { WebSocket } from 'ws';
|
|
2
4
|
|
|
3
5
|
import { sessionsDb } from '@/modules/database/index.js';
|
|
4
6
|
import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.js';
|
|
5
7
|
import { connectedClients, WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js';
|
|
8
|
+
import { getGlobalImageAssetsDir, normalizeImageDescriptors } from '@/shared/image-attachments.js';
|
|
6
9
|
import type {
|
|
7
10
|
AnyRecord,
|
|
8
11
|
AuthenticatedWebSocketRequest,
|
|
@@ -10,6 +13,37 @@ import type {
|
|
|
10
13
|
} from '@/shared/types.js';
|
|
11
14
|
import { parseIncomingJsonObject } from '@/shared/utils.js';
|
|
12
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Trust boundary for client-supplied image attachments: chat.send options come
|
|
18
|
+
* straight from the browser, and the provider runtimes read the referenced
|
|
19
|
+
* files off disk (Claude base64-encodes them into the prompt). Only images
|
|
20
|
+
* that live directly inside the global upload store (`~/.cloudcli/assets`,
|
|
21
|
+
* where POST /api/assets/images puts them) are allowed through — anything
|
|
22
|
+
* else (absolute paths elsewhere, traversal, subdirectories) is dropped.
|
|
23
|
+
*
|
|
24
|
+
* Exported for tests; `assetsRootOverride` exists only for them.
|
|
25
|
+
*/
|
|
26
|
+
export function filterImagesToUploadStore(images: unknown, assetsRootOverride?: string): AnyRecord[] {
|
|
27
|
+
const assetsRoot = path.resolve(assetsRootOverride ?? getGlobalImageAssetsDir());
|
|
28
|
+
|
|
29
|
+
return normalizeImageDescriptors(images).filter((descriptor) => {
|
|
30
|
+
// Relative paths are anchored in the store; absolute ones must already be in it.
|
|
31
|
+
const resolved = path.resolve(assetsRoot, descriptor.path);
|
|
32
|
+
const relative = path.relative(assetsRoot, resolved);
|
|
33
|
+
const isDirectChild =
|
|
34
|
+
relative.length > 0 &&
|
|
35
|
+
!relative.startsWith('..') &&
|
|
36
|
+
!path.isAbsolute(relative) &&
|
|
37
|
+
!relative.includes(path.sep) &&
|
|
38
|
+
!relative.includes('/');
|
|
39
|
+
|
|
40
|
+
if (!isDirectChild) {
|
|
41
|
+
console.warn(`[Chat] Dropping image outside the upload store: ${descriptor.path}`);
|
|
42
|
+
}
|
|
43
|
+
return isDirectChild;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
13
47
|
/**
|
|
14
48
|
* One provider runtime entry point. All five runtimes share this signature,
|
|
15
49
|
* which lets the chat handler dispatch through a provider-keyed map instead
|
|
@@ -161,6 +195,9 @@ async function handleChatSend(
|
|
|
161
195
|
// gateway writer captures and maps back to the app session id.
|
|
162
196
|
const runtimeOptions: AnyRecord = {
|
|
163
197
|
...clientOptions,
|
|
198
|
+
// Image attachments are re-validated server-side: only files inside the
|
|
199
|
+
// global upload store may reach the provider runtimes' file reads.
|
|
200
|
+
images: filterImagesToUploadStore(clientOptions.images),
|
|
164
201
|
sessionId: session.provider_session_id ?? undefined,
|
|
165
202
|
resume: Boolean(session.provider_session_id),
|
|
166
203
|
cwd: clientOptions.cwd ?? session.project_path ?? undefined,
|
|
@@ -175,8 +212,10 @@ async function handleChatSend(
|
|
|
175
212
|
} finally {
|
|
176
213
|
// Safety net: a runtime that crashed (or resolved) without emitting its
|
|
177
214
|
// terminal `complete` would otherwise leave the session stuck in
|
|
178
|
-
// "processing" forever on every connected client.
|
|
179
|
-
|
|
215
|
+
// "processing" forever on every connected client. Scoped to THIS run —
|
|
216
|
+
// a queued message can start the session's next run before this promise
|
|
217
|
+
// settles, and the session-keyed completeRun would kill that new run.
|
|
218
|
+
chatRunRegistry.completeRunIfCurrent(run, { exitCode: 1 });
|
|
180
219
|
}
|
|
181
220
|
}
|
|
182
221
|
|
|
@@ -146,14 +146,6 @@ function buildShellCommand(
|
|
|
146
146
|
return 'codex';
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
if (provider === 'gemini') {
|
|
150
|
-
const command = initialCommand || 'gemini';
|
|
151
|
-
if (resumeSessionId) {
|
|
152
|
-
return `${command} --resume "${resumeSessionId}"`;
|
|
153
|
-
}
|
|
154
|
-
return command;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
149
|
if (provider === 'opencode') {
|
|
158
150
|
if (resumeSessionId) {
|
|
159
151
|
return `opencode --session "${resumeSessionId}"`;
|
|
@@ -477,9 +469,7 @@ export function handleShellConnection(
|
|
|
477
469
|
? 'Cursor'
|
|
478
470
|
: provider === 'codex'
|
|
479
471
|
? 'Codex'
|
|
480
|
-
: provider === '
|
|
481
|
-
? 'Gemini'
|
|
482
|
-
: provider === 'opencode'
|
|
472
|
+
: provider === 'opencode'
|
|
483
473
|
? 'OpenCode'
|
|
484
474
|
: 'Claude';
|
|
485
475
|
welcomeMsg = hasSession && resumeSessionId
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
|
|
6
|
+
import { filterImagesToUploadStore } from '@/modules/websocket/services/chat-websocket.service.js';
|
|
7
|
+
|
|
8
|
+
const STORE = path.join(os.tmpdir(), 'cloudcli-assets-store');
|
|
9
|
+
|
|
10
|
+
test('images inside the upload store pass through', () => {
|
|
11
|
+
const inside = path.join(STORE, 'shot.png');
|
|
12
|
+
const result = filterImagesToUploadStore(
|
|
13
|
+
[{ path: inside, name: 'shot.png', mimeType: 'image/png' }],
|
|
14
|
+
STORE,
|
|
15
|
+
);
|
|
16
|
+
assert.equal(result.length, 1);
|
|
17
|
+
assert.equal(result[0].path, inside);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('bare filenames are anchored inside the store', () => {
|
|
21
|
+
const result = filterImagesToUploadStore(['shot.png'], STORE);
|
|
22
|
+
assert.equal(result.length, 1);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('paths outside the store, traversal, and subdirs are dropped', () => {
|
|
26
|
+
const result = filterImagesToUploadStore(
|
|
27
|
+
[
|
|
28
|
+
{ path: 'C:/Users/victim/.ssh/id_rsa' },
|
|
29
|
+
{ path: '/etc/passwd' },
|
|
30
|
+
{ path: '../outside.png' },
|
|
31
|
+
{ path: path.join(STORE, '..', 'escaped.png') },
|
|
32
|
+
{ path: path.join(STORE, 'nested', 'deep.png') },
|
|
33
|
+
{ path: STORE }, // the store folder itself is not a file
|
|
34
|
+
],
|
|
35
|
+
STORE,
|
|
36
|
+
);
|
|
37
|
+
assert.deepEqual(result, []);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('malformed payloads yield no images', () => {
|
|
41
|
+
assert.deepEqual(filterImagesToUploadStore(undefined, STORE), []);
|
|
42
|
+
assert.deepEqual(filterImagesToUploadStore('nope', STORE), []);
|
|
43
|
+
assert.deepEqual(filterImagesToUploadStore([{ name: 'no-path' }, 42], STORE), []);
|
|
44
|
+
});
|
|
@@ -129,6 +129,44 @@ test('complete marks the run finished and duplicate completes are dropped', asyn
|
|
|
129
129
|
});
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
+
test('a finished run\'s safety net cannot complete the session\'s next run', async () => {
|
|
133
|
+
await withIsolatedDatabase(() => {
|
|
134
|
+
sessionsDb.createAppSession('app-run-9', 'codex', '/workspace/demo');
|
|
135
|
+
const connection = new FakeConnection();
|
|
136
|
+
|
|
137
|
+
const firstRun = chatRunRegistry.startRun({
|
|
138
|
+
appSessionId: 'app-run-9',
|
|
139
|
+
provider: 'codex',
|
|
140
|
+
providerSessionId: null,
|
|
141
|
+
connection,
|
|
142
|
+
userId: null,
|
|
143
|
+
});
|
|
144
|
+
assert.ok(firstRun);
|
|
145
|
+
firstRun.writer.send({ kind: 'complete', provider: 'codex', sessionId: 'native-9', exitCode: 0 });
|
|
146
|
+
|
|
147
|
+
// A queued message starts the next run before the first run's runtime
|
|
148
|
+
// promise settles (the chat handler's `finally` hasn't executed yet).
|
|
149
|
+
const secondRun = chatRunRegistry.startRun({
|
|
150
|
+
appSessionId: 'app-run-9',
|
|
151
|
+
provider: 'codex',
|
|
152
|
+
providerSessionId: null,
|
|
153
|
+
connection,
|
|
154
|
+
userId: null,
|
|
155
|
+
});
|
|
156
|
+
assert.ok(secondRun);
|
|
157
|
+
|
|
158
|
+
// First run's safety net fires late: it must not touch the new run.
|
|
159
|
+
chatRunRegistry.completeRunIfCurrent(firstRun, { exitCode: 1 });
|
|
160
|
+
assert.equal(chatRunRegistry.isProcessing('app-run-9'), true);
|
|
161
|
+
assert.equal(connection.frames.filter((frame) => frame.kind === 'complete').length, 1);
|
|
162
|
+
|
|
163
|
+
// The second run's own safety net still works while it is current.
|
|
164
|
+
chatRunRegistry.completeRunIfCurrent(secondRun, { exitCode: 1 });
|
|
165
|
+
assert.equal(chatRunRegistry.isProcessing('app-run-9'), false);
|
|
166
|
+
assert.equal(connection.frames.filter((frame) => frame.kind === 'complete').length, 2);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
132
170
|
test('listRunningRuns returns only currently running app sessions', async () => {
|
|
133
171
|
await withIsolatedDatabase(() => {
|
|
134
172
|
sessionsDb.createAppSession('app-run-7', 'claude', '/workspace/demo');
|
|
@@ -186,22 +224,22 @@ test('replayEvents returns only events after the requested seq', async () => {
|
|
|
186
224
|
|
|
187
225
|
test('attachConnection reroutes the live stream to a new socket', async () => {
|
|
188
226
|
await withIsolatedDatabase(() => {
|
|
189
|
-
sessionsDb.createAppSession('app-run-5', '
|
|
227
|
+
sessionsDb.createAppSession('app-run-5', 'opencode', '/workspace/demo');
|
|
190
228
|
const firstConnection = new FakeConnection();
|
|
191
229
|
const run = chatRunRegistry.startRun({
|
|
192
230
|
appSessionId: 'app-run-5',
|
|
193
|
-
provider: '
|
|
231
|
+
provider: 'opencode',
|
|
194
232
|
providerSessionId: null,
|
|
195
233
|
connection: firstConnection,
|
|
196
234
|
userId: null,
|
|
197
235
|
});
|
|
198
236
|
assert.ok(run);
|
|
199
237
|
|
|
200
|
-
run.writer.send({ kind: 'stream_delta', provider: '
|
|
238
|
+
run.writer.send({ kind: 'stream_delta', provider: 'opencode', sessionId: 'o', content: 'before' });
|
|
201
239
|
|
|
202
240
|
const secondConnection = new FakeConnection();
|
|
203
241
|
assert.equal(chatRunRegistry.attachConnection('app-run-5', secondConnection), true);
|
|
204
|
-
run.writer.send({ kind: 'stream_delta', provider: '
|
|
242
|
+
run.writer.send({ kind: 'stream_delta', provider: 'opencode', sessionId: 'o', content: 'after' });
|
|
205
243
|
|
|
206
244
|
assert.deepEqual(firstConnection.frames.map((frame) => frame.content), ['before']);
|
|
207
245
|
assert.deepEqual(secondConnection.frames.map((frame) => frame.content), ['after']);
|
package/server/openai-codex.js
CHANGED
|
@@ -14,13 +14,14 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { Codex } from '@openai/codex-sdk';
|
|
17
|
+
|
|
18
|
+
import { buildCodexInputItems, normalizeImageDescriptors } from './shared/image-attachments.js';
|
|
17
19
|
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
|
18
20
|
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
|
19
21
|
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
|
20
22
|
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
|
21
23
|
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
|
22
24
|
|
|
23
|
-
// Track active sessions
|
|
24
25
|
const activeCodexSessions = new Map();
|
|
25
26
|
|
|
26
27
|
function readUsageNumber(value) {
|
|
@@ -228,6 +229,8 @@ export async function queryCodex(command, options = {}, ws) {
|
|
|
228
229
|
cwd,
|
|
229
230
|
projectPath,
|
|
230
231
|
model,
|
|
232
|
+
effort,
|
|
233
|
+
images,
|
|
231
234
|
permissionMode = 'default'
|
|
232
235
|
} = options;
|
|
233
236
|
|
|
@@ -239,6 +242,12 @@ export async function queryCodex(command, options = {}, ws) {
|
|
|
239
242
|
|
|
240
243
|
const workingDirectory = cwd || projectPath || process.cwd();
|
|
241
244
|
const { sandboxMode, approvalPolicy } = mapPermissionModeToCodexOptions(permissionMode);
|
|
245
|
+
const catalog = (await providerModelsService.getProviderModels('codex')).models;
|
|
246
|
+
const selectedModel = catalog.OPTIONS.find((option) => option.value === resolvedModel) || null;
|
|
247
|
+
const allowedEfforts = selectedModel?.effort?.values?.map((value) => value.value) || [];
|
|
248
|
+
const resolvedEffort = typeof effort === 'string' && effort !== 'default' && allowedEfforts.includes(effort)
|
|
249
|
+
? effort
|
|
250
|
+
: undefined;
|
|
242
251
|
|
|
243
252
|
let codex;
|
|
244
253
|
let thread;
|
|
@@ -248,19 +257,17 @@ export async function queryCodex(command, options = {}, ws) {
|
|
|
248
257
|
const abortController = new AbortController();
|
|
249
258
|
|
|
250
259
|
try {
|
|
251
|
-
// Initialize Codex SDK
|
|
252
260
|
codex = new Codex();
|
|
253
261
|
|
|
254
|
-
// Thread options with sandbox and approval settings
|
|
255
262
|
const threadOptions = {
|
|
256
263
|
workingDirectory,
|
|
257
264
|
skipGitRepoCheck: true,
|
|
258
265
|
sandboxMode,
|
|
259
266
|
approvalPolicy,
|
|
260
|
-
model: resolvedModel
|
|
267
|
+
model: resolvedModel,
|
|
268
|
+
modelReasoningEffort: resolvedEffort,
|
|
261
269
|
};
|
|
262
270
|
|
|
263
|
-
// Start or resume thread
|
|
264
271
|
if (sessionId) {
|
|
265
272
|
thread = codex.resumeThread(sessionId, threadOptions);
|
|
266
273
|
} else {
|
|
@@ -280,13 +287,16 @@ export async function queryCodex(command, options = {}, ws) {
|
|
|
280
287
|
});
|
|
281
288
|
};
|
|
282
289
|
|
|
283
|
-
// Existing sessions can be tracked immediately; new sessions are tracked after thread.started.
|
|
284
290
|
if (capturedSessionId) {
|
|
285
291
|
registerSession(capturedSessionId);
|
|
286
292
|
}
|
|
287
293
|
|
|
288
|
-
// Execute with streaming
|
|
289
|
-
|
|
294
|
+
// Execute with streaming. Turns with image attachments send structured
|
|
295
|
+
// input items so Codex reads the images from their local asset paths.
|
|
296
|
+
const turnInput = normalizeImageDescriptors(images).length > 0
|
|
297
|
+
? buildCodexInputItems(command, images, workingDirectory)
|
|
298
|
+
: command;
|
|
299
|
+
const streamedTurn = await thread.runStreamed(turnInput, {
|
|
290
300
|
signal: abortController.signal
|
|
291
301
|
});
|
|
292
302
|
|
package/server/opencode-cli.js
CHANGED
|
@@ -1,19 +1,59 @@
|
|
|
1
|
-
import { spawn } from 'child_process';
|
|
2
1
|
import fsSync from 'node:fs';
|
|
3
2
|
|
|
4
3
|
import crossSpawn from 'cross-spawn';
|
|
5
4
|
import Database from 'better-sqlite3';
|
|
6
5
|
|
|
6
|
+
import { appendImagesInputTag } from './shared/image-attachments.js';
|
|
7
7
|
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
|
8
8
|
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
|
9
9
|
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
|
10
10
|
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
|
11
|
-
import { createCompleteMessage, createNormalizedMessage, getOpenCodeDatabasePath } from './shared/utils.js';
|
|
11
|
+
import { createCompleteMessage, createNormalizedMessage, flattenPromptForWindowsShell, getOpenCodeDatabasePath } from './shared/utils.js';
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// cross-spawn resolves .cmd shims/PATHEXT on Windows and delegates to
|
|
14
|
+
// child_process.spawn everywhere else.
|
|
15
|
+
const spawnFunction = crossSpawn;
|
|
14
16
|
|
|
15
17
|
const activeOpenCodeProcesses = new Map();
|
|
16
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Maps the UI permission mode onto OpenCode's non-interactive controls.
|
|
21
|
+
*
|
|
22
|
+
* OpenCode has no single "permission mode" flag; each mode uses a different
|
|
23
|
+
* lever of the `opencode run` CLI (verified against v1.17.13):
|
|
24
|
+
* - plan → the built-in read-only `plan` agent (`--agent plan`).
|
|
25
|
+
* - bypassPermissions → `--auto`, which auto-approves every permission that
|
|
26
|
+
* is not explicitly denied in the user's config.
|
|
27
|
+
* - acceptEdits → the OPENCODE_PERMISSION env var, whose JSON body the
|
|
28
|
+
* CLI merges into its permission config. Forcing
|
|
29
|
+
* `edit: allow` guarantees file edits go through while
|
|
30
|
+
* every other rule stays under the user's own config.
|
|
31
|
+
* - default → nothing; the user's opencode.json governs. In
|
|
32
|
+
* non-interactive `run` mode any `ask` rule is denied.
|
|
33
|
+
*
|
|
34
|
+
* Exported for tests only.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveOpenCodePermissionOptions(permissionMode) {
|
|
37
|
+
switch (permissionMode) {
|
|
38
|
+
case 'plan':
|
|
39
|
+
return { args: ['--agent', 'plan'], env: {} };
|
|
40
|
+
case 'bypassPermissions':
|
|
41
|
+
return { args: ['--auto'], env: {} };
|
|
42
|
+
case 'acceptEdits':
|
|
43
|
+
return { args: [], env: { OPENCODE_PERMISSION: JSON.stringify({ edit: 'allow' }) } };
|
|
44
|
+
default:
|
|
45
|
+
return { args: [], env: {} };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolveOpenCodeEffort(model, effort, modelsDefinition) {
|
|
50
|
+
const selectedModel = modelsDefinition?.OPTIONS?.find((option) => option.value === model);
|
|
51
|
+
const allowedEfforts = selectedModel?.effort?.values?.map((value) => value.value) || [];
|
|
52
|
+
return typeof effort === 'string' && effort !== 'default' && allowedEfforts.includes(effort)
|
|
53
|
+
? effort
|
|
54
|
+
: undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
17
57
|
function readOpenCodeSessionId(event) {
|
|
18
58
|
if (!event || typeof event !== 'object') {
|
|
19
59
|
return null;
|
|
@@ -84,7 +124,7 @@ function readOpenCodeTokenUsage(sessionId) {
|
|
|
84
124
|
|
|
85
125
|
async function spawnOpenCode(command, options = {}, ws) {
|
|
86
126
|
return new Promise((resolve, reject) => {
|
|
87
|
-
const { sessionId, projectPath, cwd, model, sessionSummary } = options;
|
|
127
|
+
const { sessionId, projectPath, cwd, model, effort, sessionSummary, images, permissionMode } = options;
|
|
88
128
|
const workingDir = cwd || projectPath || process.cwd();
|
|
89
129
|
const processKey = sessionId || Date.now().toString();
|
|
90
130
|
let capturedSessionId = sessionId || null;
|
|
@@ -192,7 +232,15 @@ async function spawnOpenCode(command, options = {}, ws) {
|
|
|
192
232
|
}
|
|
193
233
|
};
|
|
194
234
|
|
|
195
|
-
void providerModelsService.resolveResumeModel('opencode', sessionId, model).then((resolvedModel) => {
|
|
235
|
+
void providerModelsService.resolveResumeModel('opencode', sessionId, model).then(async (resolvedModel) => {
|
|
236
|
+
let effortModels = null;
|
|
237
|
+
try {
|
|
238
|
+
effortModels = (await providerModelsService.getProviderModels('opencode')).models;
|
|
239
|
+
} catch (error) {
|
|
240
|
+
console.warn('[OpenCode] Unable to load provider models for effort validation:', error);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const resolvedEffort = resolveOpenCodeEffort(resolvedModel, effort, effortModels);
|
|
196
244
|
const args = ['run', '--format', 'json'];
|
|
197
245
|
// OpenCode's `run` command owns workspace selection through `--dir`.
|
|
198
246
|
// Relying on the child-process cwd alone is not enough on Linux, where
|
|
@@ -204,14 +252,23 @@ async function spawnOpenCode(command, options = {}, ws) {
|
|
|
204
252
|
if (resolvedModel) {
|
|
205
253
|
args.push('--model', resolvedModel);
|
|
206
254
|
}
|
|
255
|
+
if (resolvedEffort) {
|
|
256
|
+
args.push('--variant', resolvedEffort);
|
|
257
|
+
}
|
|
258
|
+
const permissionOptions = resolveOpenCodePermissionOptions(permissionMode);
|
|
259
|
+
args.push(...permissionOptions.args);
|
|
207
260
|
if (command && command.trim()) {
|
|
208
|
-
|
|
261
|
+
// Image attachments ride along as an <images_input> path list appended
|
|
262
|
+
// to the prompt; the session history reader strips the tag back out.
|
|
263
|
+
// opencode is a .cmd shim on Windows, so the whole argument must be
|
|
264
|
+
// newline-free or cmd.exe silently truncates it at the first newline.
|
|
265
|
+
args.push(flattenPromptForWindowsShell(appendImagesInputTag(command.trim(), images)));
|
|
209
266
|
}
|
|
210
267
|
|
|
211
268
|
opencodeProcess = spawnFunction('opencode', args, {
|
|
212
269
|
cwd: workingDir,
|
|
213
270
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
214
|
-
env: { ...process.env },
|
|
271
|
+
env: { ...process.env, ...permissionOptions.env },
|
|
215
272
|
});
|
|
216
273
|
|
|
217
274
|
activeOpenCodeProcesses.set(processKey, opencodeProcess);
|
|
@@ -4,7 +4,7 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import test from 'node:test';
|
|
6
6
|
|
|
7
|
-
import { spawnOpenCode } from './opencode-cli.js';
|
|
7
|
+
import { resolveOpenCodePermissionOptions, spawnOpenCode } from './opencode-cli.js';
|
|
8
8
|
|
|
9
9
|
const findEnvKey = (name) =>
|
|
10
10
|
Object.keys(process.env).find((key) => key.toLowerCase() === name.toLowerCase()) || name;
|
|
@@ -14,7 +14,10 @@ async function createFakeOpenCodeExecutable(binDir) {
|
|
|
14
14
|
await writeFile(scriptPath, `
|
|
15
15
|
const capturePath = process.env.OPENCODE_ARGS_CAPTURE;
|
|
16
16
|
if (capturePath) {
|
|
17
|
-
require('node:fs').writeFileSync(capturePath, JSON.stringify(
|
|
17
|
+
require('node:fs').writeFileSync(capturePath, JSON.stringify({
|
|
18
|
+
args: process.argv.slice(2),
|
|
19
|
+
permissionEnv: process.env.OPENCODE_PERMISSION ?? null,
|
|
20
|
+
}));
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
const events = [
|
|
@@ -86,10 +89,116 @@ test('spawnOpenCode emits session_created before normalized live messages for ne
|
|
|
86
89
|
assert.equal(complete?.sessionId, 'open-live-1');
|
|
87
90
|
assert.equal(messages.some((message) => message.kind === 'error'), false);
|
|
88
91
|
|
|
89
|
-
const
|
|
92
|
+
const capture = JSON.parse(await readFile(argsCapturePath, 'utf8'));
|
|
93
|
+
const launchedArgs = capture.args;
|
|
90
94
|
assert.ok(Array.isArray(launchedArgs));
|
|
91
95
|
assert.deepEqual(launchedArgs.slice(0, 4), ['run', '--format', 'json', '--dir']);
|
|
92
96
|
assert.equal(launchedArgs[4], tempRoot);
|
|
97
|
+
// No permission mode requested → no permission flags and no env override.
|
|
98
|
+
assert.equal(launchedArgs.includes('--auto'), false);
|
|
99
|
+
assert.equal(launchedArgs.includes('--agent'), false);
|
|
100
|
+
assert.equal(capture.permissionEnv, null);
|
|
101
|
+
} finally {
|
|
102
|
+
if (previousPath === undefined) {
|
|
103
|
+
delete process.env[pathKey];
|
|
104
|
+
} else {
|
|
105
|
+
process.env[pathKey] = previousPath;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (previousPathExt === undefined) {
|
|
109
|
+
delete process.env[pathExtKey];
|
|
110
|
+
} else {
|
|
111
|
+
process.env[pathExtKey] = previousPathExt;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (previousArgsCapture === undefined) {
|
|
115
|
+
delete process.env.OPENCODE_ARGS_CAPTURE;
|
|
116
|
+
} else {
|
|
117
|
+
process.env.OPENCODE_ARGS_CAPTURE = previousArgsCapture;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('resolveOpenCodePermissionOptions maps UI permission modes onto OpenCode controls', () => {
|
|
125
|
+
assert.deepEqual(resolveOpenCodePermissionOptions('plan'), {
|
|
126
|
+
args: ['--agent', 'plan'],
|
|
127
|
+
env: {},
|
|
128
|
+
});
|
|
129
|
+
assert.deepEqual(resolveOpenCodePermissionOptions('bypassPermissions'), {
|
|
130
|
+
args: ['--auto'],
|
|
131
|
+
env: {},
|
|
132
|
+
});
|
|
133
|
+
assert.deepEqual(resolveOpenCodePermissionOptions('acceptEdits'), {
|
|
134
|
+
args: [],
|
|
135
|
+
env: { OPENCODE_PERMISSION: '{"edit":"allow"}' },
|
|
136
|
+
});
|
|
137
|
+
// default and anything unknown leave the user's own opencode config in charge.
|
|
138
|
+
assert.deepEqual(resolveOpenCodePermissionOptions('default'), { args: [], env: {} });
|
|
139
|
+
assert.deepEqual(resolveOpenCodePermissionOptions(undefined), { args: [], env: {} });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('spawnOpenCode passes permission mode flags and env to the CLI', async () => {
|
|
143
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-cli-perms-'));
|
|
144
|
+
const pathKey = findEnvKey('PATH');
|
|
145
|
+
const pathExtKey = findEnvKey('PATHEXT');
|
|
146
|
+
const previousPath = process.env[pathKey];
|
|
147
|
+
const previousPathExt = process.env[pathExtKey];
|
|
148
|
+
const previousArgsCapture = process.env.OPENCODE_ARGS_CAPTURE;
|
|
149
|
+
const writer = {
|
|
150
|
+
userId: null,
|
|
151
|
+
sessionId: null,
|
|
152
|
+
send() {},
|
|
153
|
+
setSessionId(sessionId) {
|
|
154
|
+
this.sessionId = sessionId;
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
await createFakeOpenCodeExecutable(tempRoot);
|
|
160
|
+
process.env[pathKey] = `${tempRoot}${path.delimiter}${previousPath || ''}`;
|
|
161
|
+
if (process.platform === 'win32') {
|
|
162
|
+
process.env[pathExtKey] = previousPathExt?.toUpperCase().includes('.CMD')
|
|
163
|
+
? previousPathExt
|
|
164
|
+
: `.COM;.EXE;.BAT;.CMD${previousPathExt ? `;${previousPathExt}` : ''}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const scenarios = [
|
|
168
|
+
{
|
|
169
|
+
permissionMode: 'plan',
|
|
170
|
+
expectArgs: ['--agent', 'plan'],
|
|
171
|
+
expectPermissionEnv: null,
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
permissionMode: 'bypassPermissions',
|
|
175
|
+
expectArgs: ['--auto'],
|
|
176
|
+
expectPermissionEnv: null,
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
permissionMode: 'acceptEdits',
|
|
180
|
+
expectArgs: [],
|
|
181
|
+
expectPermissionEnv: '{"edit":"allow"}',
|
|
182
|
+
},
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
for (const scenario of scenarios) {
|
|
186
|
+
const argsCapturePath = path.join(tempRoot, `opencode-args-${scenario.permissionMode}.json`);
|
|
187
|
+
process.env.OPENCODE_ARGS_CAPTURE = argsCapturePath;
|
|
188
|
+
|
|
189
|
+
await spawnOpenCode('Hi', { cwd: tempRoot, permissionMode: scenario.permissionMode }, writer);
|
|
190
|
+
|
|
191
|
+
const capture = JSON.parse(await readFile(argsCapturePath, 'utf8'));
|
|
192
|
+
for (const expectedArg of scenario.expectArgs) {
|
|
193
|
+
assert.ok(
|
|
194
|
+
capture.args.includes(expectedArg),
|
|
195
|
+
`${scenario.permissionMode}: expected "${expectedArg}" in ${JSON.stringify(capture.args)}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
// The prompt stays the last positional argument, after any permission flags.
|
|
199
|
+
assert.equal(capture.args[capture.args.length - 1], 'Hi');
|
|
200
|
+
assert.equal(capture.permissionEnv, scenario.expectPermissionEnv);
|
|
201
|
+
}
|
|
93
202
|
} finally {
|
|
94
203
|
if (previousPath === undefined) {
|
|
95
204
|
delete process.env[pathKey];
|