Compare commits

...

10 Commits

Author SHA1 Message Date
ONLY-yours ffc439fcce 🐛 fix: prevent git clone from hanging on first message
Add GIT_TERMINAL_PROMPT=0 so unauthenticated private repo clones fail
immediately instead of blocking on a credential prompt. Add timeout 120
as a hard deadline so slow clones don't stall the sandbox indefinitely.

Root cause: clone hangs → lh hetero exec never starts → WebSocket gets
no events → first message stuck forever. Second message works because
the partial clone dir already exists, so [ -d dir ] skips cloning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:25:14 +08:00
ONLY-yours 176e3490c8 🐛 fix: restore web hetero→gateway routing; update stale test
On web, a configured heterogeneousProvider always routes to gateway —
the cloud sandbox is the only execution environment regardless of
isGatewayMode. The test assumed the pre-cloud-CC world where web
ignored hetero providers entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 19:08:55 +08:00
ONLY-yours f1bc10efb2 🐛 fix: remove incorrect web hetero→gateway forced routing in agentDispatcher
On web, heterogeneousProvider is ignored — routing falls through to isGatewayMode.
Cloud CC only runs when gateway mode is enabled; gateway.ts handles sandbox
spawning when it detects a hetero provider.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 19:06:33 +08:00
ONLY-yours 33e1fa7b31 💬 i18n: add claude setup-token hint to token description
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 19:00:13 +08:00
ONLY-yours 40be523bbd 🔒 fix: address security and dead-code issues from PR review
- sandboxRunner: sanitize repo dir name to prevent shell injection
- sandboxRunner: use git insteadOf (-c flag) so token is never stored in .git/config
- cloudHeteroContext: fix return type from string|undefined to string (dead branch)
- CloudRepoSwitcher: remove unreachable empty-list branch in popover content

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 18:46:29 +08:00
ONLY-yours e6608399f7 🐛 fix: add missing getPendingTopicRepos import in gateway 2026-05-09 18:43:52 +08:00
ONLY-yours d5b74d31b9 🐛 fix: consume pendingTopicRepos only after topic creation succeeds 2026-05-09 18:31:16 +08:00
ONLY-yours f59e32ff5e ♻️ refactor: move pendingTopicRepos real impl into submodule, remove cloud override 2026-05-09 18:26:23 +08:00
ONLY-yours 4823d22560 🐛 fix: add open-source stub for pendingTopicRepos to fix Vite build 2026-05-09 18:22:04 +08:00
ONLY-yours 0405676c4a feat: Cloud Claude Code V3 — repo picker, GitHub token, sandbox context
- Add CloudRepoSwitcher component (web-only multi-select repo picker)
  - Pre-topic selections buffered in module singleton (pendingTopicRepos)
  - Consumed by gateway.ts at topic creation time via appContext.initialTopicMetadata
  - Eliminates race condition where updateTopicMetadata dropped silently
- Extend ChatTopicMetadata with repos[] field for multi-repo binding
- Add initialTopicMetadata to ExecAgentAppContext so repos are written to
  topic metadata at creation time (server-side, zero race condition)
- Extend ExecAgentSchema Zod schema with initialTopicMetadata
- Inject GITHUB_TOKEN env var into sandbox so CC can use git/gh CLI
- Build cloudHeteroContext with GitHub auth section when token is available
- Add workingDirectory selector for web (repos[0] fallback)
- Add refreshTopic call in gateway path after new topic creation
- Add CloudHeterogeneousConfig profile editor for GITHUB_REPOS / GITHUB_CRED_KEY
- Extend sandboxRunner with repo clone setup script and systemContext support
2026-05-09 18:17:26 +08:00
24 changed files with 1062 additions and 24 deletions
+4
View File
@@ -184,6 +184,10 @@
"groupWizard.searchTemplates": "Search templates...",
"groupWizard.title": "Create Group",
"groupWizard.useTemplate": "Use Template",
"heteroAgent.cloudRepo.multiSelected": "{{count}} repos selected",
"heteroAgent.cloudRepo.noRepos": "No repositories configured. Add them in agent settings.",
"heteroAgent.cloudRepo.notSet": "No repo selected",
"heteroAgent.cloudRepo.sectionTitle": "Repositories",
"heteroAgent.fullAccess.label": "Full access",
"heteroAgent.fullAccess.tooltip": "Claude Code runs locally with full read/write access to the working directory. Switching permission modes is not available yet.",
"heteroAgent.resumeReset.cwdChanged": "Working directory changed. Previous Claude Code session can only be resumed from its original directory, so a new conversation has started.",
+17
View File
@@ -291,9 +291,26 @@
"heterogeneousStatus.auth.api": "API",
"heterogeneousStatus.auth.label": "Auth Method",
"heterogeneousStatus.auth.subscription": "Subscription",
"heterogeneousStatus.cloud.githubDesc": "Select a GitHub credential to allow the sandbox to clone your private repositories.",
"heterogeneousStatus.cloud.githubLabel": "GitHub Connection",
"heterogeneousStatus.cloud.githubNoCreds": "No GitHub credentials found.",
"heterogeneousStatus.cloud.githubPlaceholder": "Select a GitHub credential...",
"heterogeneousStatus.cloud.manageCredentials": "Manage Credentials →",
"heterogeneousStatus.cloud.repoAdd": "Add",
"heterogeneousStatus.cloud.repoDesc": "Add repositories to the list. Switch the active one from the bottom bar in the chat view.",
"heterogeneousStatus.cloud.repoLabel": "Repositories",
"heterogeneousStatus.cloud.repoPlaceholder": "owner/repo or https://github.com/owner/repo",
"heterogeneousStatus.cloud.tabLabel": "Cloud",
"heterogeneousStatus.cloud.tokenCancel": "Cancel",
"heterogeneousStatus.cloud.tokenChange": "Change",
"heterogeneousStatus.cloud.tokenDesc": "Your Claude Code OAuth token. Saved securely to Credentials once submitted. Run `claude setup-token` in your terminal to generate one.",
"heterogeneousStatus.cloud.tokenLabel": "Claude Code Token",
"heterogeneousStatus.cloud.tokenPlaceholder": "Paste your OAuth token here",
"heterogeneousStatus.cloud.tokenSave": "Save",
"heterogeneousStatus.command.edit": "Edit command",
"heterogeneousStatus.command.label": "Launch Command",
"heterogeneousStatus.command.placeholder": "Command name or absolute path",
"heterogeneousStatus.desktop.tabLabel": "Desktop",
"heterogeneousStatus.detecting": "Detecting {{name}} CLI...",
"heterogeneousStatus.plan.label": "Plan",
"heterogeneousStatus.redetect": "Re-detect",
+4
View File
@@ -184,6 +184,10 @@
"groupWizard.searchTemplates": "搜索模板…",
"groupWizard.title": "创建群组",
"groupWizard.useTemplate": "使用模板",
"heteroAgent.cloudRepo.multiSelected": "已选 {{count}} 个仓库",
"heteroAgent.cloudRepo.noRepos": "未配置仓库,请在助理设置中添加。",
"heteroAgent.cloudRepo.notSet": "未选择仓库",
"heteroAgent.cloudRepo.sectionTitle": "代码仓库",
"heteroAgent.fullAccess.label": "完全访问权限",
"heteroAgent.fullAccess.tooltip": "Claude Code 在本地运行,对工作目录拥有完全的读写权限。当前暂不支持切换权限模式。",
"heteroAgent.resumeReset.cwdChanged": "工作目录已切换,之前的 Claude Code 会话只能在原目录下继续,已开始新对话。",
+17
View File
@@ -291,9 +291,26 @@
"heterogeneousStatus.auth.api": "API",
"heterogeneousStatus.auth.label": "认证方式",
"heterogeneousStatus.auth.subscription": "订阅",
"heterogeneousStatus.cloud.githubDesc": "选择 GitHub 凭证,让沙箱能够克隆你的私有仓库。",
"heterogeneousStatus.cloud.githubLabel": "GitHub 连接",
"heterogeneousStatus.cloud.githubNoCreds": "未找到 GitHub 凭证。",
"heterogeneousStatus.cloud.githubPlaceholder": "选择 GitHub 凭证…",
"heterogeneousStatus.cloud.manageCredentials": "管理凭证 →",
"heterogeneousStatus.cloud.repoAdd": "添加",
"heterogeneousStatus.cloud.repoDesc": "在此管理仓库列表,在对话视图底栏切换当前激活的仓库。",
"heterogeneousStatus.cloud.repoLabel": "代码仓库",
"heterogeneousStatus.cloud.repoPlaceholder": "owner/repo 或 https://github.com/owner/repo",
"heterogeneousStatus.cloud.tabLabel": "云端",
"heterogeneousStatus.cloud.tokenCancel": "取消",
"heterogeneousStatus.cloud.tokenChange": "修改",
"heterogeneousStatus.cloud.tokenDesc": "你的 Claude Code OAuth Token,提交后将安全保存到凭证管理。在终端运行 `claude setup-token` 即可获取。",
"heterogeneousStatus.cloud.tokenLabel": "Claude Code Token",
"heterogeneousStatus.cloud.tokenPlaceholder": "粘贴你的 OAuth Token",
"heterogeneousStatus.cloud.tokenSave": "保存",
"heterogeneousStatus.command.edit": "编辑指令",
"heterogeneousStatus.command.label": "启动指令",
"heterogeneousStatus.command.placeholder": "指令名称或绝对路径",
"heterogeneousStatus.desktop.tabLabel": "桌面端",
"heterogeneousStatus.detecting": "正在检测 {{name}} CLI…",
"heterogeneousStatus.plan.label": "方案",
"heterogeneousStatus.redetect": "重新检测",
+7
View File
@@ -10,6 +10,13 @@ export interface HeterogeneousProviderConfig {
command?: string;
/** Custom environment variables */
env?: Record<string, string>;
/**
* Static context prepended to every user prompt before it reaches the agent CLI.
* Use this to prime the agent with workspace conventions, rules, or instructions
* that should apply to every conversation.
* Combined with any runtime-generated context (e.g. cloned repo list).
*/
systemContext?: string;
/** Agent runtime type */
type: 'claude-code' | 'codex';
}
@@ -11,6 +11,14 @@ export interface ExecAgentAppContext {
documentId?: string | null;
/** Group ID for group chat */
groupId?: string | null;
/**
* Initial metadata to merge into the topic when a new topic is created for
* this execution. Ignored when a topicId is already provided (existing topic).
*/
initialTopicMetadata?: {
repos?: string[];
workingDirectory?: string;
};
/** Scope identifier */
scope?: string | null;
/** Session ID */
+10 -1
View File
@@ -105,6 +105,12 @@ export interface ChatTopicMetadata {
onboardingFeedback?: OnboardingFeedbackEntry;
onboardingSession?: OnboardingSessionSnapshot;
provider?: string;
/**
* Web (cloud) only. Ordered list of GitHub repos selected for this topic.
* Each repo will be cloned into the Gateway sandbox before execution.
* `workingDirectory` is kept in sync with repos[0] (the primary repo).
*/
repos?: string[];
/**
* Currently running Gateway operation on this topic.
* Set when agent execution starts, cleared when it completes/fails.
@@ -119,10 +125,13 @@ export interface ChatTopicMetadata {
userMemoryExtractRunState?: TopicUserMemoryExtractRunState;
userMemoryExtractStatus?: 'pending' | 'completed' | 'failed';
/**
* Topic-level working directory (desktop only).
* Topic-level working directory.
* On desktop: local filesystem path for the CC session cwd.
* On web (cloud): URL of the primary GitHub repo (first item of `repos`).
* Priority is higher than Agent-level settings. Also serves as the
* binding cwd for a CC session — written on first CC execution and
* checked on subsequent turns to decide whether `--resume` is safe.
* For sidebar grouping, topics are bucketed by this field (byProject mode).
*/
workingDirectory?: string;
}
@@ -0,0 +1,224 @@
'use client';
import { Github } from '@lobehub/icons';
import { Flexbox, Icon, Popover } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { CheckIcon, ChevronDownIcon, SquircleDashed } from 'lucide-react';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAgentStore } from '@/store/agent';
import { agentByIdSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { getPendingTopicRepos, setPendingTopicRepos } from '@/store/chat/pendingTopicRepos';
import { topicSelectors } from '@/store/chat/selectors';
const styles = createStaticStyles(({ css }) => ({
button: css`
cursor: pointer;
display: flex;
gap: 6px;
align-items: center;
padding-block: 2px;
padding-inline: 4px;
border-radius: 4px;
font-size: 12px;
color: ${cssVar.colorTextSecondary};
transition: background 0.2s;
&:hover {
background: ${cssVar.colorFillTertiary};
}
`,
checkIndicator: css`
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: 1.5px solid ${cssVar.colorBorder};
border-radius: 4px;
`,
checkIndicatorChecked: css`
border-color: ${cssVar.colorPrimary};
color: #fff;
background: ${cssVar.colorPrimary};
`,
repoItem: css`
cursor: pointer;
padding-block: 6px;
padding-inline: 8px;
border-radius: ${cssVar.borderRadius};
transition: background-color 0.2s;
&:hover {
background: ${cssVar.colorFillTertiary};
}
`,
repoName: css`
font-size: 13px;
font-weight: 500;
color: ${cssVar.colorText};
`,
repoUrl: css`
overflow: hidden;
font-size: 11px;
color: ${cssVar.colorTextDescription};
text-overflow: ellipsis;
white-space: nowrap;
`,
scrollContainer: css`
overflow-y: auto;
max-height: 360px;
`,
sectionTitle: css`
padding-block: 6px 2px;
padding-inline: 8px;
font-size: 11px;
font-weight: 500;
color: ${cssVar.colorTextQuaternary};
text-transform: uppercase;
letter-spacing: 0.5px;
`,
}));
const getRepoName = (repo: string) => repo.split('/').findLast(Boolean) || repo;
interface CloudRepoSwitcherProps {
agentId: string;
}
const CloudRepoSwitcher = memo<CloudRepoSwitcherProps>(({ agentId }) => {
const { t } = useTranslation('chat');
const [open, setOpen] = useState(false);
// Incremented to trigger re-renders when the module-singleton pending selection changes.
const [, forceUpdate] = useState(0);
// Available repos configured on the agent
const availableRepos: string[] = useAgentStore((s) => {
const env = agentByIdSelectors.getAgencyConfigById(agentId)(s)?.heterogeneousProvider?.env;
try {
return JSON.parse(env?.GITHUB_REPOS ?? '[]');
} catch {
return [];
}
});
// Repos persisted to the current topic (empty when no topic or none set)
const topicRepos: string[] = useChatStore((s) => {
const meta = topicSelectors.currentTopicMetadata(s);
return meta?.repos ?? [];
});
const activeTopicId = useChatStore((s) => s.activeTopicId);
const updateTopicMetadata = useChatStore((s) => s.updateTopicMetadata);
const currentWorkingDirectory = useChatStore(
(s) => topicSelectors.currentTopicMetadata(s)?.workingDirectory,
);
const toggleRepo = useCallback(
async (repo: string) => {
if (!activeTopicId) {
// No topic yet — buffer in the module singleton keyed by agentId.
// gateway.ts will read and consume this when the first message creates a topic.
const prev = getPendingTopicRepos(agentId);
const isSelected = prev.includes(repo);
const next = isSelected ? prev.filter((r) => r !== repo) : [...prev, repo];
setPendingTopicRepos(agentId, next);
forceUpdate((v) => v + 1);
return;
}
const isSelected = topicRepos.includes(repo);
const nextRepos = isSelected ? topicRepos.filter((r) => r !== repo) : [...topicRepos, repo];
// Only set workingDirectory when it hasn't been assigned yet (first selection).
// Once set, it stays fixed so the topic keeps its sidebar grouping.
const patch: { repos: string[]; workingDirectory?: string } = { repos: nextRepos };
if (!currentWorkingDirectory && nextRepos.length > 0) {
patch.workingDirectory = nextRepos[0];
}
await updateTopicMetadata(activeTopicId, patch);
},
[agentId, activeTopicId, currentWorkingDirectory, topicRepos, updateTopicMetadata],
);
if (availableRepos.length === 0) return null;
// When a topic exists, show its persisted repos.
// When no topic exists yet, show the pending selection from the module singleton.
const displayRepos = activeTopicId ? topicRepos : getPendingTopicRepos(agentId);
// Button label
const buttonLabel =
displayRepos.length === 0
? t('heteroAgent.cloudRepo.notSet')
: displayRepos.length === 1
? getRepoName(displayRepos[0])
: t('heteroAgent.cloudRepo.multiSelected', { count: displayRepos.length });
const content = (
<Flexbox gap={4} style={{ minWidth: 280 }}>
<div className={styles.sectionTitle}>{t('heteroAgent.cloudRepo.sectionTitle')}</div>
<div className={styles.scrollContainer}>
{availableRepos.map((repo) => {
const isChecked = displayRepos.includes(repo);
return (
<Flexbox
horizontal
align="center"
className={styles.repoItem}
gap={8}
key={repo}
onClick={() => toggleRepo(repo)}
>
<div
className={`${styles.checkIndicator} ${isChecked ? styles.checkIndicatorChecked : ''}`}
>
{isChecked && <Icon icon={CheckIcon} size={12} />}
</div>
<Github size={16} style={{ color: cssVar.colorTextTertiary, flex: 'none' }} />
<Flexbox flex={1} style={{ minWidth: 0 }}>
<div className={styles.repoName}>{getRepoName(repo)}</div>
<div className={styles.repoUrl}>{repo}</div>
</Flexbox>
</Flexbox>
);
})}
</div>
</Flexbox>
);
return (
<Popover
content={content}
open={open}
placement="bottomLeft"
styles={{ content: { padding: 4 } }}
trigger="click"
onOpenChange={setOpen}
>
<div className={styles.button}>
{displayRepos.length > 0 ? <Github size={14} /> : <Icon icon={SquircleDashed} size={14} />}
<span>{buttonLabel}</span>
<Icon icon={ChevronDownIcon} size={12} />
</div>
</Popover>
);
});
CloudRepoSwitcher.displayName = 'CloudRepoSwitcher';
export default CloudRepoSwitcher;
+10 -1
View File
@@ -23,6 +23,7 @@ import { topicSelectors } from '@/store/chat/selectors';
import { useAgentId } from '../hooks/useAgentId';
import { useUpdateAgentConfig } from '../hooks/useUpdateAgentConfig';
import ApprovalMode from './ApprovalMode';
import CloudRepoSwitcher from './CloudRepoSwitcher';
import GitStatus from './GitStatus';
import { useRepoType } from './useRepoType';
import WorkingDirectory from './WorkingDirectory';
@@ -104,9 +105,10 @@ const RuntimeConfig = memo(() => {
const [dirPopoverOpen, setDirPopoverOpen] = useState(false);
const [modePopoverOpen, setModePopoverOpen] = useState(false);
const [isLoading, runtimeMode] = useAgentStore((s) => [
const [isLoading, runtimeMode, isHeterogeneous] = useAgentStore((s) => [
agentByIdSelectors.isAgentConfigLoadingById(agentId)(s),
chatConfigByIdSelectors.getRuntimeModeById(agentId)(s),
agentId ? agentByIdSelectors.isAgentHeterogeneousById(agentId)(s) : false,
]);
const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory);
@@ -227,6 +229,13 @@ const RuntimeConfig = memo(() => {
);
const rightContent = () => {
// Web + heterogeneous agent always shows the cloud repo switcher,
// regardless of the stored runtimeMode (which may be 'local' from desktop).
if (!isDesktop && isHeterogeneous && agentId) {
return <CloudRepoSwitcher agentId={agentId} />;
}
// Desktop local mode: show working directory picker
if (runtimeMode === 'local') {
return (
<>
+4
View File
@@ -166,6 +166,10 @@ export default {
'Claude Code sessions are pinned to a working directory. Switching will start a new session for this topic — chat messages stay, but the previous session context cannot be resumed.',
'heteroAgent.switchCwd.ok': 'Switch and start new session',
'heteroAgent.switchCwd.title': 'Switch working directory?',
'heteroAgent.cloudRepo.sectionTitle': 'Repositories',
'heteroAgent.cloudRepo.notSet': 'No repo selected',
'heteroAgent.cloudRepo.noRepos': 'No repositories configured. Add them in agent settings.',
'heteroAgent.cloudRepo.multiSelected': '{{count}} repos selected',
'hideForYou':
"Direct message content is hidden. Please enable 'Show Direct Message Content' in settings to view.",
'history.title': 'The Agent will keep only the latest {{count}} messages.',
+25
View File
@@ -219,6 +219,31 @@ export default {
'heterogeneousStatus.plan.label': 'Plan',
'heterogeneousStatus.redetect': 'Re-detect',
'heterogeneousStatus.unavailable': '{{name}} CLI not found. Please install or configure it.',
// Heterogeneous agent — Cloud tab (web environment config)
'heterogeneousStatus.cloud.tabLabel': 'Cloud',
'heterogeneousStatus.cloud.tokenLabel': 'Claude Code Token',
'heterogeneousStatus.cloud.tokenDesc':
'Your Claude Code OAuth token. Saved securely to Credentials once submitted. Run `claude setup-token` in your terminal to generate one.',
'heterogeneousStatus.cloud.tokenPlaceholder': 'Paste your OAuth token here',
'heterogeneousStatus.cloud.tokenChange': 'Change',
'heterogeneousStatus.cloud.tokenSave': 'Save',
'heterogeneousStatus.cloud.tokenCancel': 'Cancel',
'heterogeneousStatus.cloud.githubLabel': 'GitHub Connection',
'heterogeneousStatus.cloud.githubDesc':
'Select a GitHub OAuth credential to allow the sandbox to clone your private repositories.',
'heterogeneousStatus.cloud.githubPlaceholder': 'Select a GitHub credential...',
'heterogeneousStatus.cloud.githubNoCreds': 'No GitHub credentials found.',
'heterogeneousStatus.cloud.manageCredentials': 'Manage Credentials →',
'heterogeneousStatus.cloud.repoLabel': 'Repositories',
'heterogeneousStatus.cloud.repoDesc':
'Add repositories to the list. Switch the active one from the bottom bar in the chat view.',
'heterogeneousStatus.cloud.repoPlaceholder': 'owner/repo or https://github.com/owner/repo',
'heterogeneousStatus.cloud.repoAdd': 'Add',
// Heterogeneous agent — Desktop tab
'heterogeneousStatus.desktop.tabLabel': 'Desktop',
'checking': 'Checking...',
// Credentials Management
@@ -1,5 +1,6 @@
'use client';
import { isDesktop } from '@lobechat/const';
import { Github } from '@lobehub/icons';
import { Flexbox, Icon, Popover, Skeleton, Tooltip } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
@@ -14,6 +15,7 @@ import { memo, type ReactNode, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAgentId } from '@/features/ChatInput/hooks/useAgentId';
import CloudRepoSwitcher from '@/features/ChatInput/RuntimeConfig/CloudRepoSwitcher';
import GitStatus from '@/features/ChatInput/RuntimeConfig/GitStatus';
import { useRepoType } from '@/features/ChatInput/RuntimeConfig/useRepoType';
import WorkingDirectoryContent from '@/features/ChatInput/RuntimeConfig/WorkingDirectory';
@@ -69,6 +71,7 @@ const WorkingDirectoryBar = memo(() => {
const agentId = useAgentId();
const [open, setOpen] = useState(false);
// All hooks must be called unconditionally (Rules of Hooks)
const isLoading = useAgentStore(agentByIdSelectors.isAgentConfigLoadingById(agentId));
const agentWorkingDirectory = useAgentStore((s) =>
agentId ? agentByIdSelectors.getAgentWorkingDirectoryById(agentId)(s) : undefined,
@@ -85,6 +88,16 @@ const WorkingDirectoryBar = memo(() => {
return <Icon icon={FolderIcon} size={14} />;
}, [effectiveWorkingDirectory, repoType]);
// On web, show the cloud repo switcher instead of the local directory picker
if (!isDesktop) {
if (!agentId) return null;
return (
<Flexbox horizontal align={'center'} className={styles.bar} justify={'space-between'}>
<CloudRepoSwitcher agentId={agentId} />
</Flexbox>
);
}
if (!agentId || isLoading) {
return (
<Flexbox horizontal align={'center'} className={styles.bar} gap={4} justify={'space-between'}>
@@ -0,0 +1,384 @@
'use client';
import { type HeterogeneousProviderConfig, type UserCredSummary } from '@lobechat/types';
import { Github } from '@lobehub/icons';
import { Flexbox } from '@lobehub/ui';
import { Avatar, Button, Input, Select, Spin, Tag, Typography } from 'antd';
import { createStyles } from 'antd-style';
import { CheckCircle2, KeyRound, X } from 'lucide-react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { lambdaClient, lambdaQuery } from '@/libs/trpc/client';
// Fixed cred key for Claude Code OAuth token — never changes
const CLAUDE_TOKEN_CRED_KEY = 'CLAUDE_CODE_OAUTH_TOKEN';
const useStyles = createStyles(({ css, token, cssVar }) => ({
card: css`
padding-block: 16px 12px;
padding-inline: 16px;
border: 1px solid ${token.colorBorderSecondary};
border-radius: ${token.borderRadiusLG}px;
background: ${token.colorBgContainer};
`,
credOption: css`
display: flex;
gap: 8px;
align-items: center;
`,
manageLink: css`
cursor: pointer;
font-size: 12px;
color: ${cssVar.colorPrimary};
&:hover {
text-decoration: underline;
}
`,
repoItem: css`
cursor: pointer;
display: flex;
gap: 8px;
align-items: center;
min-height: 36px;
padding-block: 6px;
padding-inline: 8px;
border-radius: ${token.borderRadiusSM}px;
transition: background 0.15s;
&:hover {
background: ${token.colorFillTertiary};
.repo-delete-btn {
opacity: 1;
}
}
`,
repoItemActive: css`
background: ${token.colorFillSecondary};
`,
repoDeleteBtn: css`
cursor: pointer;
flex-shrink: 0;
margin-inline-start: auto;
padding: 2px;
border: none;
border-radius: 4px;
color: ${token.colorTextTertiary};
opacity: 0;
background: transparent;
transition:
opacity 0.15s,
color 0.15s;
&:hover {
color: ${token.colorError};
}
`,
repoList: css`
display: flex;
flex-direction: column;
gap: 2px;
`,
sectionDesc: css`
font-size: 12px;
color: ${token.colorTextSecondary};
`,
sectionDivider: css`
margin-block: 12px;
border-block-start: 1px solid ${token.colorBorderSecondary};
`,
sectionLabel: css`
font-size: 12px;
color: ${token.colorTextTertiary};
text-transform: uppercase;
letter-spacing: 0.04em;
`,
}));
interface CloudHeterogeneousConfigProps {
onEnvChange: (env: Record<string, string>) => Promise<void> | void;
provider: HeterogeneousProviderConfig;
}
// ── Claude Code Token section ──────────────────────────────────────────────
interface TokenSectionProps {
existingCred: UserCredSummary | undefined;
onEnvChange: (patch: Record<string, string>) => void;
onSaved: () => void;
}
const TokenSection = memo<TokenSectionProps>(({ existingCred, onSaved, onEnvChange }) => {
const { t } = useTranslation('setting');
const { styles } = useStyles();
const [editing, setEditing] = useState(!existingCred);
const [tokenInput, setTokenInput] = useState('');
const [saving, setSaving] = useState(false);
const handleSave = async () => {
const token = tokenInput.trim();
if (!token) return;
setSaving(true);
try {
await lambdaClient.market.creds.createKV.mutate({
key: CLAUDE_TOKEN_CRED_KEY,
name: 'Claude Code OAuth Token',
type: 'kv-env',
values: { [CLAUDE_TOKEN_CRED_KEY]: token },
});
onEnvChange({ CLAUDE_CODE_CRED_KEY: CLAUDE_TOKEN_CRED_KEY });
setTokenInput('');
setEditing(false);
onSaved();
} finally {
setSaving(false);
}
};
return (
<Flexbox gap={8}>
<Flexbox horizontal align="center" justify="space-between">
<Flexbox horizontal align="center" gap={6}>
<KeyRound size={12} />
<span className={styles.sectionLabel}>{t('heterogeneousStatus.cloud.tokenLabel')}</span>
</Flexbox>
{existingCred && !editing && (
<span className={styles.manageLink} onClick={() => setEditing(true)}>
{t('heterogeneousStatus.cloud.tokenChange')}
</span>
)}
</Flexbox>
{existingCred && !editing ? (
<Flexbox horizontal align="center" gap={8}>
<Tag
color="success"
icon={<CheckCircle2 size={11} />}
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
>
{existingCred.maskedPreview ?? existingCred.name}
</Tag>
</Flexbox>
) : (
<Flexbox horizontal gap={8}>
<Input.Password
autoFocus={!!existingCred}
placeholder={t('heterogeneousStatus.cloud.tokenPlaceholder')}
style={{ flex: 1 }}
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
onPressEnter={handleSave}
/>
<Button loading={saving} type="primary" onClick={handleSave}>
{t('heterogeneousStatus.cloud.tokenSave')}
</Button>
{existingCred && (
<Button
onClick={() => {
setEditing(false);
setTokenInput('');
}}
>
{t('heterogeneousStatus.cloud.tokenCancel')}
</Button>
)}
</Flexbox>
)}
<span className={styles.sectionDesc}>{t('heterogeneousStatus.cloud.tokenDesc')}</span>
</Flexbox>
);
});
// ── Repo list section ──────────────────────────────────────────────────────
// Profile page: manage the list of repos (add / delete only).
// Active repo selection happens in the bottom-left CloudRepoSwitcher.
interface RepoListSectionProps {
onReposChange: (repos: string[]) => void;
repos: string[];
}
const RepoListSection = memo<RepoListSectionProps>(({ repos, onReposChange }) => {
const { t } = useTranslation('setting');
const { styles } = useStyles();
const [input, setInput] = useState('');
const addRepo = () => {
const v = input.trim();
if (!v || repos.includes(v)) return;
onReposChange([...repos, v]);
setInput('');
};
const removeRepo = (repo: string, e: React.MouseEvent) => {
e.stopPropagation();
onReposChange(repos.filter((r) => r !== repo));
};
return (
<Flexbox gap={8}>
<span className={styles.sectionLabel}>{t('heterogeneousStatus.cloud.repoLabel')}</span>
{repos.length > 0 && (
<div className={styles.repoList}>
{repos.map((repo) => (
<div className={styles.repoItem} key={repo}>
<Github size={14} style={{ flexShrink: 0 }} />
<Typography.Text ellipsis style={{ flex: 1, fontSize: 13 }}>
{repo}
</Typography.Text>
<button
className={`${styles.repoDeleteBtn} repo-delete-btn`}
onClick={(e) => removeRepo(repo, e)}
>
<X size={12} />
</button>
</div>
))}
</div>
)}
<Flexbox horizontal gap={8}>
<Input
placeholder={t('heterogeneousStatus.cloud.repoPlaceholder')}
style={{ flex: 1 }}
value={input}
onChange={(e) => setInput(e.target.value)}
onPressEnter={addRepo}
/>
<Button onClick={addRepo}>{t('heterogeneousStatus.cloud.repoAdd')}</Button>
</Flexbox>
<span className={styles.sectionDesc}>{t('heterogeneousStatus.cloud.repoDesc')}</span>
</Flexbox>
);
});
// ── Main component ─────────────────────────────────────────────────────────
const CloudHeterogeneousConfig = memo<CloudHeterogeneousConfigProps>(
({ provider, onEnvChange }) => {
const { t } = useTranslation('setting');
const { styles } = useStyles();
const navigate = useNavigate();
const currentEnv = provider.env ?? {};
const storedGithubCredKey = currentEnv.GITHUB_CRED_KEY ?? '';
const repos: string[] = (() => {
try {
return JSON.parse(currentEnv.GITHUB_REPOS ?? '[]');
} catch {
return [];
}
})();
const {
data: credsData,
isLoading,
refetch,
} = lambdaQuery.market.creds.list.useQuery(undefined);
const allCreds: UserCredSummary[] = credsData?.data ?? [];
const claudeTokenCred = allCreds.find((c) => c.key === CLAUDE_TOKEN_CRED_KEY);
const githubCreds = allCreds.filter(
(c) => c.type === 'oauth' && c.oauthProvider?.toLowerCase().includes('github'),
);
const saveEnv = (patch: Record<string, string>) => {
void onEnvChange({ ...currentEnv, ...patch });
};
const handleReposChange = (nextRepos: string[]) => {
saveEnv({ GITHUB_REPOS: JSON.stringify(nextRepos) });
};
if (isLoading) {
return (
<Flexbox align="center" justify="center" style={{ paddingBlock: 32 }}>
<Spin size="small" />
</Flexbox>
);
}
return (
<div className={styles.card}>
<Flexbox gap={16}>
{/* ── Claude Code OAuth Token ── */}
<TokenSection
existingCred={claudeTokenCred}
onEnvChange={saveEnv}
onSaved={() => refetch()}
/>
<div className={styles.sectionDivider} />
{/* ── GitHub OAuth Credential ── */}
<Flexbox gap={8}>
<Flexbox horizontal align="center" justify="space-between">
<Flexbox horizontal align="center" gap={6}>
<Github size={12} />
<span className={styles.sectionLabel}>
{t('heterogeneousStatus.cloud.githubLabel')}
</span>
</Flexbox>
<span className={styles.manageLink} onClick={() => navigate('/settings/creds')}>
{t('heterogeneousStatus.cloud.manageCredentials')}
</span>
</Flexbox>
<Select
allowClear
placeholder={t('heterogeneousStatus.cloud.githubPlaceholder')}
style={{ width: '100%' }}
value={storedGithubCredKey || undefined}
notFoundContent={
<Flexbox style={{ padding: '8px 0', fontSize: 12 }}>
{t('heterogeneousStatus.cloud.githubNoCreds')}
</Flexbox>
}
onChange={(key: string) => saveEnv({ GITHUB_CRED_KEY: key })}
onClear={() => saveEnv({ GITHUB_CRED_KEY: '' })}
>
{githubCreds.map((cred) => (
<Select.Option key={cred.key} value={cred.key}>
<span className={styles.credOption}>
{cred.oauthAvatar ? (
<Avatar size={16} src={cred.oauthAvatar} />
) : (
<Github size={14} />
)}
<span>{cred.name}</span>
{cred.oauthUsername && (
<Typography.Text style={{ fontSize: 12 }} type="secondary">
@{cred.oauthUsername}
</Typography.Text>
)}
</span>
</Select.Option>
))}
</Select>
<span className={styles.sectionDesc}>{t('heterogeneousStatus.cloud.githubDesc')}</span>
</Flexbox>
<div className={styles.sectionDivider} />
{/* ── Repository list ── */}
<RepoListSection repos={repos} onReposChange={handleReposChange} />
</Flexbox>
</div>
);
},
);
export default CloudHeterogeneousConfig;
@@ -1,9 +1,11 @@
'use client';
import { isDesktop } from '@lobechat/const';
import { Flexbox } from '@lobehub/ui';
import { Divider } from 'antd';
import { Divider, Tabs } from 'antd';
import isEqual from 'fast-deep-equal';
import React, { memo } from 'react';
import { useTranslation } from 'react-i18next';
import ModelSelect from '@/features/ModelSelect';
import { useAgentStore } from '@/store/agent';
@@ -15,23 +17,31 @@ import AgentSettings from '../AgentSettings';
import EditorCanvas from '../EditorCanvas';
import AgentHeader from './AgentHeader';
import AgentTool from './AgentTool';
import CloudHeterogeneousConfig from './CloudHeterogeneousConfig';
import HeterogeneousAgentStatusCard from './HeterogeneousAgentStatusCard';
const ProfileEditor = memo(() => {
const { t } = useTranslation('setting');
const config = useAgentStore(agentSelectors.currentAgentConfig, isEqual);
const updateConfig = useAgentStore((s) => s.updateAgentConfig);
const isHeterogeneous = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous);
const enableBusinessFeatures = useServerConfigStore(serverConfigSelectors.enableBusinessFeatures);
const heterogeneousProvider = config.agencyConfig?.heterogeneousProvider;
const updateHeterogeneousCommand = async (command: string) => {
if (!heterogeneousProvider) return;
await updateConfig({
agencyConfig: {
heterogeneousProvider: {
...heterogeneousProvider,
command,
},
heterogeneousProvider: { ...heterogeneousProvider, command },
},
});
};
const updateHeterogeneousEnv = async (env: Record<string, string>) => {
if (!heterogeneousProvider) return;
await updateConfig({
agencyConfig: {
heterogeneousProvider: { ...heterogeneousProvider, env },
},
});
};
@@ -47,10 +57,33 @@ const ProfileEditor = memo(() => {
{/* Header: Avatar + Name + Description */}
<AgentHeader />
{isHeterogeneous && heterogeneousProvider ? (
// Heterogeneous integration mode: show provider CLI status instead of model/skills pickers
<HeterogeneousAgentStatusCard
provider={heterogeneousProvider}
onCommandChange={updateHeterogeneousCommand}
// Heterogeneous integration mode: tabs for cloud (web) and desktop environments
<Tabs
defaultActiveKey={isDesktop ? 'desktop' : 'cloud'}
size="small"
items={[
{
key: 'cloud',
label: t('heterogeneousStatus.cloud.tabLabel'),
children: (
<CloudHeterogeneousConfig
provider={heterogeneousProvider}
onEnvChange={updateHeterogeneousEnv}
/>
),
},
{
key: 'desktop',
label: t('heterogeneousStatus.desktop.tabLabel'),
disabled: !isDesktop,
children: (
<HeterogeneousAgentStatusCard
provider={heterogeneousProvider}
onCommandChange={updateHeterogeneousCommand}
/>
),
},
]}
/>
) : (
<>
+6
View File
@@ -142,6 +142,12 @@ const ExecAgentSchema = z
defaultTaskAssigneeAgentId: z.string().optional(),
documentId: z.string().optional().nullable(),
groupId: z.string().optional().nullable(),
initialTopicMetadata: z
.object({
repos: z.array(z.string()).optional(),
workingDirectory: z.string().optional(),
})
.optional(),
scope: z.string().optional().nullable(),
sessionId: z.string().optional(),
taskId: z.string().optional().nullable(),
+1
View File
@@ -626,6 +626,7 @@ export const topicRouter = router({
})
.nullable()
.optional(),
repos: z.array(z.string()).optional(),
workingDirectory: z.string().optional(),
}),
}),
+43 -2
View File
@@ -588,14 +588,20 @@ export class AiAgentService {
throw new Error('Resume mode requires the parent message to belong to a topic');
}
// Prepare metadata with cronJobId, taskId, botContext, and bound device if provided
// Prepare metadata with cronJobId, taskId, botContext, bound device, and any
// client-supplied initial metadata (e.g. repos selected before first message).
const initialTopicMeta = appContext?.initialTopicMetadata;
const metadata =
cronJobId || operationTaskId || botContext || requestedDeviceId
cronJobId || operationTaskId || botContext || requestedDeviceId || initialTopicMeta
? {
bot: botContext,
boundDeviceId: requestedDeviceId,
cronJobId: cronJobId || undefined,
taskId: operationTaskId,
...(initialTopicMeta?.repos && { repos: initialTopicMeta.repos }),
...(initialTopicMeta?.workingDirectory && {
workingDirectory: initialTopicMeta.workingDirectory,
}),
}
: undefined;
@@ -674,12 +680,47 @@ export class AiAgentService {
throw new Error('Failed to sign operation JWT for hetero agent', { cause: err });
}
// Read repos from topic metadata for sandbox setup (web/cloud only).
const topic = await this.topicModel.findById(topicId);
const topicRepos: string[] = topic?.metadata?.repos ?? [];
// Resolve GitHub OAuth token so the sandbox can clone private repos.
let githubToken: string | undefined;
if (topicRepos.length > 0) {
const githubCredKey = agentConfig.agencyConfig?.heterogeneousProvider?.env?.GITHUB_CRED_KEY;
if (githubCredKey) {
try {
const list = await this.marketService.market.creds.list();
const cred = list.data?.find((c: { key: string }) => c.key === githubCredKey);
if (cred) {
const full = await this.marketService.market.creds.get(cred.id, { decrypt: true });
const vals = (full as any).plaintext ?? (full as any).values ?? {};
githubToken = vals.access_token ?? vals.token;
}
} catch (err) {
log('execAgent: failed to resolve GitHub token for repo clone: %O', err);
}
}
}
// Build cloud-specific system context (repo list + workspace info + optional agent-level static context).
const { buildCloudHeteroContext } =
await import('@/server/services/heterogeneousAgent/cloudHeteroContext');
const systemContext = buildCloudHeteroContext({
agentSystemContext: agentConfig.agencyConfig?.heterogeneousProvider?.systemContext,
githubToken,
repos: topicRepos,
});
const heteroParams = {
agentType: heteroType,
githubToken,
jwt: operationJwt,
operationId,
prompt,
repos: topicRepos,
resumeSessionId,
systemContext,
topicId,
userId: this.userId,
};
@@ -0,0 +1,92 @@
/**
* Builds the system context injected before every user prompt for cloud Claude Code runs.
*
* This context is cloud-sandbox-specific: it describes the workspace layout,
* lists the GitHub repos that were pre-cloned, and tells CC how to handle
* repos that may not have been cloned successfully.
*
* It is NOT the agent's systemRole (which lives in agentConfig.systemRole and
* is a user-facing persona definition). This is pure infra context for CC.
*
* Returned string is passed as the first text block in the --input-json array
* via sandboxRunner → spawnHeteroSandbox. If nothing meaningful to inject,
* returns undefined so no extra block is added.
*/
export function buildCloudHeteroContext(params: {
repos: string[];
/** Static systemContext from HeterogeneousProviderConfig.systemContext (agent-level). */
agentSystemContext?: string;
/** GitHub OAuth token injected as GITHUB_TOKEN env var in the sandbox. */
githubToken?: string;
}): string {
const { repos, agentSystemContext, githubToken } = params;
const parts: string[] = [];
// --- Agent-level static context (highest priority, goes first) ---
if (agentSystemContext?.trim()) {
parts.push(agentSystemContext.trim());
}
// --- Cloud workspace context ---
const workspaceLines: string[] = [
'## Cloud Workspace',
'You are running inside a LobeHub cloud sandbox. Your working directory is `/workspace`.',
];
if (githubToken) {
workspaceLines.push(
'',
'## GitHub Authentication',
'A GitHub OAuth token is available as the `GITHUB_TOKEN` environment variable.',
'Use it for:',
'- Authenticated git operations (`git clone`, `git push`, `git pull`, etc.)',
'- GitHub CLI: `gh` commands work out of the box',
'- GitHub API calls: pass it as `Authorization: Bearer $GITHUB_TOKEN`',
'- Accessing private repositories',
);
}
if (repos.length > 0) {
workspaceLines.push(
'',
'## GitHub Repositories',
'The following repositories were pre-cloned into `/workspace` before this conversation started:',
...repos.map((repo) => {
const dir = repoToLocalDir(repo);
const url = toGithubUrl(repo);
return `- \`/workspace/${dir}\` (${url})`;
}),
'',
'You can start working in any of these directories immediately.',
githubToken
? 'If a directory is missing (clone may have failed), you can recover it yourself using the available GITHUB_TOKEN.'
: 'If a directory is missing (clone may have failed), you can run `git clone <url> /workspace/<dir>` yourself to recover it.',
);
} else {
workspaceLines.push(
'',
'No GitHub repositories have been pre-cloned for this conversation.',
githubToken
? 'If you need a repository, you can clone it yourself using the available GITHUB_TOKEN.'
: 'If you need a repository, ask the user to add it in the repo selector, or clone it yourself with `git clone <url> /workspace/<dir>`.',
);
}
parts.push(workspaceLines.join('\n'));
return parts.join('\n\n');
}
// ---------------------------------------------------------------------------
// Helpers (mirrors sandboxRunner logic — kept local to avoid coupling)
// ---------------------------------------------------------------------------
function repoToLocalDir(repo: string): string {
return (repo.split('/').findLast(Boolean) ?? repo).replace(/\.git$/, '');
}
function toGithubUrl(repo: string): string {
if (repo.startsWith('http')) return repo.replace(/\.git$/, '');
return `https://github.com/${repo}`;
}
@@ -8,16 +8,64 @@ const log = debug('lobe-server:hetero-sandbox-runner');
export interface SandboxRunParams {
agentType: 'claude-code' | 'codex';
cwd?: string;
/** GitHub OAuth token for cloning private repos. */
githubToken?: string;
/** Operation-scoped JWT injected as LOBEHUB_JWT env in the sandbox. */
jwt: string;
marketService: MarketService;
operationId: string;
prompt: string;
/** GitHub repos to clone before running the agent (e.g. ['owner/repo', ...]). */
repos?: string[];
resumeSessionId?: string;
/**
* Optional context injected as a text block BEFORE the user's prompt.
* Useful for priming CC with workspace state (cloned repos, env info, etc.).
* Passed via --input-json as a JSON content-block array — lh already supports this.
*/
systemContext?: string;
topicId: string;
userId: string;
}
/**
* Derive the local directory name from a repo identifier.
* Accepts "owner/repo", "https://github.com/owner/repo", or "https://github.com/owner/repo.git".
* Only allows safe characters to prevent shell injection.
*/
function repoToLocalDir(repo: string): string {
const raw = (repo.split('/').findLast(Boolean) ?? repo).replace(/\.git$/, '');
return raw.replaceAll(/[^\w.-]/g, '');
}
/**
* Build an idempotent setup script that clones each repo if not already present.
* Uses `[ -d <dir> ] || git clone ...` so re-runs on the same sandbox are no-ops.
* Returns null when repos is empty.
*/
function buildRepoSetupScript(repos: string[], githubToken?: string): string | null {
if (!repos || repos.length === 0) return null;
const lines = repos.map((repo) => {
const dir = repoToLocalDir(repo);
// Normalise to "owner/repo" for the clone URL
const repoPath = repo.startsWith('http') ? (repo.split('github.com/')[1] ?? repo) : repo;
// Use git's insteadOf rewrite (passed via -c, not stored in .git/config) so the token
// never ends up in the cloned repo's remote URL.
// GIT_TERMINAL_PROMPT=0 prevents git from blocking on a credential prompt for
// private repos — it fails immediately instead of hanging the whole sandbox.
// timeout 120 provides a hard deadline so a slow clone never stalls the first message.
const cloneCmd = githubToken
? `GIT_TERMINAL_PROMPT=0 timeout 120 git -c "url.https://oauth2:${githubToken}@github.com/.insteadOf=https://github.com/" clone -q https://github.com/${repoPath} '${dir}'`
: `GIT_TERMINAL_PROMPT=0 timeout 120 git clone -q 'https://github.com/${repoPath}' '${dir}'`;
// `|| true` makes clone failures non-fatal — CC still runs even if a repo can't be cloned.
return `{ [ -d '${dir}' ] || ${cloneCmd}; } || true`;
});
return lines.join(' && \\\n');
}
/**
* Launches `lh hetero exec` inside the cloud sandbox via `runCommand`.
*
@@ -34,16 +82,22 @@ export interface SandboxRunParams {
export async function spawnHeteroSandbox(params: SandboxRunParams): Promise<void> {
const {
agentType,
cwd,
githubToken,
jwt,
marketService,
operationId,
prompt,
repos,
resumeSessionId,
topicId,
userId,
} = params;
// cwd is only set when explicitly passed (e.g. desktop local path).
// For cloud sandbox, CC always runs in the sandbox root (/workspace); repos are cloned
// as subdirectories there and CC navigates into them via its own tools.
const { cwd } = params;
// Build the `lh hetero exec` command string.
// Prompt is passed via --input-json stdin ('-') to avoid shell quoting issues
// with arbitrary user text in --prompt.
@@ -71,14 +125,32 @@ export async function spawnHeteroSandbox(params: SandboxRunParams): Promise<void
// Encode the prompt as base64 to avoid all shell quoting issues.
// echo + shell quoting mangled inner JSON quotes; base64 is quote-safe.
const stdinPayload = JSON.stringify(prompt);
// When systemContext is provided, send a content-block array so CC sees the
// context block first, then the user's actual message. lh already handles
// JSON arrays via coerceJsonPrompt — no lh changes required.
const { systemContext } = params;
const stdinPayload = systemContext
? JSON.stringify([
{ text: systemContext, type: 'text' },
{ text: prompt, type: 'text' },
])
: JSON.stringify(prompt);
const base64Payload = Buffer.from(stdinPayload).toString('base64');
// LOBEHUB_HETERO_SERVER_URL overrides the server URL for local dev/testing
// (e.g. a cloudflare tunnel). APP_URL is NOT used here because it's tied to
// auth callbacks and must stay as localhost in dev.
const serverUrl = process.env.LOBEHUB_HETERO_SERVER_URL ?? appEnv.APP_URL;
const shellCommand = `echo ${base64Payload} | base64 -d | LOBEHUB_JWT=${JSON.stringify(jwt)} LOBEHUB_SERVER=${JSON.stringify(serverUrl)} ${args.join(' ')}`;
const envVars = [
`LOBEHUB_JWT=${JSON.stringify(jwt)}`,
`LOBEHUB_SERVER=${JSON.stringify(serverUrl)}`,
// Inject GitHub token so CC can authenticate git operations and GitHub API
// calls inside the sandbox (e.g. gh CLI, git push, API requests).
...(githubToken ? [`GITHUB_TOKEN=${JSON.stringify(githubToken)}`] : []),
].join(' ');
const mainCommand = `echo ${base64Payload} | base64 -d | ${envVars} ${args.join(' ')}`;
const setupScript = buildRepoSetupScript(repos ?? [], githubToken);
const shellCommand = setupScript ? `${setupScript} && \\\n${mainCommand}` : mainCommand;
log(
'spawnHeteroSandbox: userId=%s op=%s type=%s topic=%s',
+34
View File
@@ -0,0 +1,34 @@
/**
* Module-level singleton for pending repo selections.
*
* When a user selects GitHub repos before sending the first message (no topic
* exists yet), the selections are buffered here keyed by agentId. As soon as
* the server creates a topic for that agent, gateway.ts consumes these repos
* and writes them into the topic metadata immediately — avoiding the race
* condition where the store action would drop the update because the topic
* object hadn't appeared in topicDataMap yet.
*
* Desktop builds: CloudRepoSwitcher is never rendered, so these functions are
* never called and the map stays empty.
*/
const map = new Map<string, string[]>();
/** Record pending repos for an agent (overwrites previous value). */
export const setPendingTopicRepos = (agentId: string, repos: string[]): void => {
if (repos.length === 0) map.delete(agentId);
else map.set(agentId, [...repos]);
};
/**
* Consume and return pending repos for an agent.
* Clears the entry so a second call returns [].
*/
export const consumePendingTopicRepos = (agentId: string): string[] => {
const repos = map.get(agentId);
map.delete(agentId);
return repos ?? [];
};
/** Read pending repos without consuming them (for display). */
export const getPendingTopicRepos = (agentId: string): string[] => map.get(agentId) ?? [];
@@ -16,13 +16,13 @@ describe('selectRuntimeType', () => {
expect(selectRuntimeType({ isGatewayMode: true }, opts)).toBe('gateway');
});
it('ignores heterogeneousProvider on web — falls through to gateway/client', () => {
it('routes heterogeneousProvider to gateway on web — cloud sandbox is the only execution env', () => {
expect(
selectRuntimeType({ heterogeneousProvider: heteroProvider, isGatewayMode: true }, opts),
).toBe('gateway');
expect(
selectRuntimeType({ heterogeneousProvider: heteroProvider, isGatewayMode: false }, opts),
).toBe('client');
).toBe('gateway');
});
});
@@ -46,6 +46,9 @@ export const selectRuntimeType = (
): AgentRuntimeType => {
if (ctx.parentRuntime) return ctx.parentRuntime;
if (isDesktop && ctx.heterogeneousProvider) return 'hetero';
// On web, heterogeneous agents always run via Gateway sandbox regardless of the
// isGatewayMode user preference — the sandbox is the only execution environment.
if (!isDesktop && ctx.heterogeneousProvider) return 'gateway';
if (ctx.isGatewayMode) return 'gateway';
return 'client';
};
@@ -10,6 +10,7 @@ import { isDesktop } from '@/const/version';
import { aiAgentService, type ResumeApprovalParam } from '@/services/aiAgent';
import { messageService } from '@/services/message';
import { topicService } from '@/services/topic';
import { consumePendingTopicRepos, getPendingTopicRepos } from '@/store/chat/pendingTopicRepos';
import type { ChatStore } from '@/store/chat/store';
import type { StoreSetter } from '@/store/types';
import { useUserStore } from '@/store/user';
@@ -291,6 +292,17 @@ export class GatewayActionImpl {
const isCreateNewTopic = !context.topicId;
const taskId = context.viewedTask?.type === 'detail' ? context.viewedTask.taskId : undefined;
// If this is a new topic, read any repos the user pre-selected before
// sending the first message. We read without consuming yet — if execAgentTask
// fails or is aborted, the selection is preserved so a retry can still pick
// it up. We clear only after the server confirms the topic was created.
const pendingRepos =
isCreateNewTopic && context.agentId ? getPendingTopicRepos(context.agentId) : [];
const initialTopicMetadata =
pendingRepos.length > 0
? { repos: pendingRepos, workingDirectory: pendingRepos[0] }
: undefined;
// Honour user-initiated cancel during phase-1 init: while we await the
// execAgentTask round-trip the caller's loading state (e.g. `sendMessage`)
// is still running, so the ChatInput stop button is active. Forward the
@@ -309,6 +321,7 @@ export class GatewayActionImpl {
defaultTaskAssigneeAgentId: context.defaultTaskAssigneeAgentId,
documentId: context.documentId,
groupId: context.groupId,
...(initialTopicMetadata && { initialTopicMetadata }),
scope: context.scope,
taskId,
threadId: context.threadId,
@@ -337,6 +350,8 @@ export class GatewayActionImpl {
// If server created a new topic, fetch messages first then switch topic
// (same pattern as client mode: replaceMessages before switchTopic to avoid skeleton flash)
if (isCreateNewTopic && result.topicId) {
// Topic created successfully — now safe to clear the pending repo selection.
if (context.agentId) consumePendingTopicRepos(context.agentId);
try {
const newContext = { ...context, topicId: result.topicId };
const messages = await messageService.getMessages(newContext);
@@ -350,6 +365,14 @@ export class GatewayActionImpl {
skipRefreshMessage: true,
});
// Refresh the topic list so the new topic appears in topicDataMap (sidebar).
// Unlike the direct-API sendMessage path (which receives topics[] in the
// response and calls internal_updateTopics), the gateway path only gets a
// topicId — we must explicitly refetch so the sidebar shows the new topic.
this.#get()
.refreshTopic()
.catch((err) => console.error('[Gateway] refreshTopic after topic creation failed:', err));
if (abortSignal?.aborted) {
aiAgentService
.interruptTask({ operationId: result.operationId })
+13 -5
View File
@@ -76,15 +76,22 @@ const currentActiveTopicSummary = (s: ChatStoreState): ChatTopicSummary | undefi
};
};
const currentTopicMetadata = (s: ChatStoreState) => currentActiveTopic(s)?.metadata;
/**
* Get current active topic's working directory
* Returns undefined if no topic is active or no working directory is set
* Get current active topic's working directory.
* On desktop: local filesystem path.
* On web (cloud): primary GitHub repo URL (repos[0]), or workingDirectory if set directly.
*/
const currentTopicWorkingDirectory = (s: ChatStoreState): string | undefined => {
if (!isDesktop) return;
const activeTopic = currentActiveTopic(s);
return activeTopic?.metadata?.workingDirectory;
if (!activeTopic) return;
if (isDesktop) return activeTopic.metadata?.workingDirectory;
// Web: return primary repo from repos list, or workingDirectory if set directly
const meta = activeTopic.metadata;
return meta?.repos?.[0] ?? meta?.workingDirectory;
};
const isCreatingTopic = (s: ChatStoreState) => s.creatingTopic;
@@ -175,6 +182,7 @@ export const topicSelectors = {
currentTopicCount,
currentTopicData,
currentTopicLength,
currentTopicMetadata,
currentTopicWorkingDirectory,
currentTopics,
currentTopicsWithoutCron,