cicy-desktop 2.1.239 → 2.1.241

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.
@@ -6,7 +6,7 @@
6
6
  <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
7
7
  <link rel="icon" type="image/png" sizes="256x256" href="./favicon-256.png" />
8
8
  <title>CiCy Desktop</title>
9
- <script type="module" crossorigin src="./assets/index-DEVBS0FI.js"></script>
9
+ <script type="module" crossorigin src="./assets/index-CCx0HFAk.js"></script>
10
10
  <link rel="stylesheet" crossorigin href="./assets/index-C6Zrdgc0.css">
11
11
  </head>
12
12
  <body>
@@ -572,8 +572,10 @@ async function pullCustomTeams() {
572
572
  for (const [k, v] of Object.entries(nodes)) { if (v?.cloud_team_id != null) byCloudId[String(v.cloud_team_id)] = k; }
573
573
  let added = 0, updated = 0;
574
574
  for (const t of list.teams) {
575
- if (t.kind !== "custom") continue;
576
- const host = String(t.host_url || t.hostUrl || "").trim();
575
+ // custom teams (remote URL) OR any team with a zero-trust gateway address:
576
+ // gateway teams are reached at <slug>.gw.cicy-ai.com (preferred over host_url).
577
+ if (t.kind !== "custom" && !t.gateway_url) continue;
578
+ const host = String(t.gateway_url || t.host_url || t.hostUrl || "").trim();
577
579
  if (!host) continue;
578
580
  const tid = t.teamId || t.id || null;
579
581
  const localId = tid != null ? byCloudId[String(tid)] : null;
@@ -1099,4 +1101,26 @@ function avatarForUrl(url) {
1099
1101
  return "";
1100
1102
  }
1101
1103
 
1102
- module.exports = { list, openTeam, reloadTeam, closeLocalWindows, addTeam, removeTeam, updateTeam, upgradeTeam, syncAllLocalTeams, setAvatar, getAvatars, avatarForUrl };
1104
+ // Remove teams tied to a cloud ACCOUNT (remote base_url pulled from the cloud
1105
+ // via pullCustomTeams, or user-added remote nodes) so switching/leaving an account
1106
+ // doesn't leak them onto the next user. LOCAL machine teams on THIS device
1107
+ // (localhost base_url) are kept — they belong to the box, not the account. The
1108
+ // next login re-pulls the new account's own teams fresh.
1109
+ async function purgeAccountTeams() {
1110
+ let removed = 0;
1111
+ try {
1112
+ await writeNodes((nodes) => {
1113
+ for (const id of Object.keys(nodes)) {
1114
+ let host = "";
1115
+ try { host = new URL(String(nodes[id]?.base_url || "")).hostname.toLowerCase(); } catch {}
1116
+ const isLocal = host === "" || host === "127.0.0.1" || host === "localhost" || host === "::1";
1117
+ if (!isLocal) { delete nodes[id]; removed++; }
1118
+ }
1119
+ return nodes;
1120
+ });
1121
+ } catch (e) { log.warn(`[local-teams] purgeAccountTeams failed: ${e.message}`); }
1122
+ if (removed) { _cache = null; _cacheUntil = 0; log.info(`[local-teams] purged ${removed} account team(s) (login/logout)`); }
1123
+ return removed;
1124
+ }
1125
+
1126
+ module.exports = { list, openTeam, reloadTeam, closeLocalWindows, addTeam, removeTeam, updateTeam, upgradeTeam, syncAllLocalTeams, purgeAccountTeams, setAvatar, getAvatars, avatarForUrl };
@@ -293,7 +293,7 @@ function register({ sidecarLogPath } = {}) {
293
293
  await ensureDockerTeam().catch(() => {});
294
294
  const env = { CICY_AI_GATEWAY_LLM_ENDPOINT: GATEWAY_ENDPOINT };
295
295
  if (dockerTeamReg && dockerTeamReg.apiKey) env.CICY_AI_GATEWAY_LLM_API_KEY = dockerTeamReg.apiKey;
296
- return { port: APP_PORT, container: APP_CONTAINER, volume: APP_VOLUME, env, extraPorts: readExtraPorts() };
296
+ return { port: APP_PORT, container: APP_CONTAINER, volume: APP_VOLUME, env, extraPorts: readExtraPorts(), dockerSock: sidecar.isDood() };
297
297
  };
298
298
  // Register the running :8008 instance as a (custom) team so the card's "打开"
299
299
  // reuses the token-injected open/reload flow. addTeam dedups by host:port.
@@ -675,6 +675,29 @@ function register({ sidecarLogPath } = {}) {
675
675
  }
676
676
  });
677
677
 
678
+ // 「容器内使用 Docker」(Docker-outside-of-Docker)开关: 读/写 dood flag。set 后重建容器
679
+ // 让 docker.sock + WSL 的 docker 客户端挂载生效(runContainer 内部读 dood flag)。不再下载
680
+ // docker CLI(改为直接挂 WSL 现成的二进制,秒生效,避免卡在"保存中")。进度推抽屉(op:restart)。
681
+ ipcMain.handle("sidecar:get-dood", () => {
682
+ try { return { ok: true, dood: sidecar.isDood() }; } catch (e) { return { ok: false, dood: false, error: e.message }; }
683
+ });
684
+ ipcMain.handle("sidecar:set-dood", async (e, on) => {
685
+ const emit = (ev) => { try { e.sender.send("sidecar:op-progress", { op: "restart", ...ev }); } catch {} };
686
+ try {
687
+ sidecar.setDood(!!on);
688
+ emit({ phase: "swap", status: "running", message: `容器 Docker 访问已${on ? "开启" : "关闭"},正在重建容器…` });
689
+ try { await appDocker.recreate({ ...(await appOpts()), onProgress: emit }); }
690
+ catch (err) { try { await appDocker.dockerRestart?.({ onProgress: emit }); } catch {} }
691
+ let up = false;
692
+ for (let i = 0; i < 240; i++) { if ((await appDocker.probeHealth?.(PORT)) || (await sidecar.probeExisting(PORT))) { up = true; break; } await new Promise((r) => setTimeout(r, 500)); }
693
+ emit({ phase: "done", status: up ? "done" : "error", message: up ? `容器 Docker 访问已${on ? "开启" : "关闭"}` : "重建后 :8008 未就绪——稍等或重试" });
694
+ return { ok: up, dood: !!on };
695
+ } catch (err) {
696
+ emit({ phase: "done", status: "error", message: `设置失败:${err.message}` });
697
+ return { ok: false, error: err.message };
698
+ }
699
+ });
700
+
678
701
  // Update: stop + spawn cicy-code@latest (or reload the Docker image on
679
702
  // win32). The npx re-resolve / image pull can take a while on a cold cache,
680
703
  // so allow a longer window for :8008 to come back.
@@ -570,5 +570,17 @@
570
570
  "tabShell": {
571
571
  "myTeam": "My Team",
572
572
  "newTabBtn": "New tab"
573
+ },
574
+ "dood": {
575
+ "menu": "Use Docker in container",
576
+ "hint": "Mount the host Docker + docker client into the container so its agent can run docker (toggling rebuilds the container; takes effect instantly, no download)",
577
+ "title": "Use Docker in container",
578
+ "subtitle": "Mount the host Docker + docker client into the container so its agent can run docker (instant, no download)",
579
+ "enable": "Allow Docker inside the container",
580
+ "applying": "Enabling container Docker access, rebuilding…",
581
+ "disabling": "Disabling…",
582
+ "onDone": "Container Docker access enabled",
583
+ "offDone": "Container Docker access disabled",
584
+ "failed": "Failed to apply"
573
585
  }
574
586
  }
@@ -570,5 +570,17 @@
570
570
  "tabShell": {
571
571
  "myTeam": "Mon équipe",
572
572
  "newTabBtn": "Nouvel onglet"
573
+ },
574
+ "dood": {
575
+ "menu": "Utiliser Docker dans le conteneur",
576
+ "hint": "Monte le Docker et le client docker de l'hôte dans le conteneur pour que son agent puisse exécuter docker (le basculement recrée le conteneur ; effet immédiat, sans téléchargement)",
577
+ "title": "Utiliser Docker dans le conteneur",
578
+ "subtitle": "Monte le Docker et le client docker de l'hôte dans le conteneur pour que son agent puisse exécuter docker (immédiat, sans téléchargement)",
579
+ "enable": "Autoriser Docker dans le conteneur",
580
+ "applying": "Activation de l'accès Docker du conteneur, reconstruction…",
581
+ "disabling": "Désactivation…",
582
+ "onDone": "Accès Docker du conteneur activé",
583
+ "offDone": "Accès Docker du conteneur désactivé",
584
+ "failed": "Échec de l'application"
573
585
  }
574
586
  }
@@ -570,5 +570,17 @@
570
570
  "tabShell": {
571
571
  "myTeam": "マイチーム",
572
572
  "newTabBtn": "新しいタブ"
573
+ },
574
+ "dood": {
575
+ "menu": "コンテナ内で Docker を使う",
576
+ "hint": "ホストの Docker と docker クライアントをコンテナにマウントし、agent が docker を実行できるようにします(切り替えでコンテナを再作成、即時反映・ダウンロード不要)",
577
+ "title": "コンテナ内で Docker を使う",
578
+ "subtitle": "ホストの Docker と docker クライアントをコンテナにマウントして、agent が docker を直接実行できます(即時反映、ダウンロード不要)",
579
+ "enable": "コンテナ内での Docker を許可",
580
+ "applying": "コンテナ Docker アクセスを有効化、再作成中…",
581
+ "disabling": "無効化中…",
582
+ "onDone": "コンテナ Docker アクセスを有効化しました",
583
+ "offDone": "コンテナ Docker アクセスを無効化しました",
584
+ "failed": "設定に失敗しました"
573
585
  }
574
586
  }
@@ -570,5 +570,17 @@
570
570
  "tabShell": {
571
571
  "myTeam": "我的团队",
572
572
  "newTabBtn": "新建标签"
573
+ },
574
+ "dood": {
575
+ "menu": "容器内使用 Docker",
576
+ "hint": "把宿主 Docker + docker 客户端挂进容器,容器内 agent 能直接跑 docker(切换会重建容器,秒生效、无需下载)",
577
+ "title": "容器内使用 Docker",
578
+ "subtitle": "把宿主 Docker + docker 客户端挂进容器,容器内 agent 就能直接跑 docker(秒生效,无需下载)",
579
+ "enable": "允许容器内使用 Docker",
580
+ "applying": "开启容器 Docker 访问,重建容器中…",
581
+ "disabling": "关闭中…",
582
+ "onDone": "容器 Docker 访问已开启",
583
+ "offDone": "容器 Docker 访问已关闭",
584
+ "failed": "设置失败"
573
585
  }
574
586
  }
package/src/main.js CHANGED
@@ -998,6 +998,17 @@ electronApp.whenReady().then(async () => {
998
998
  // renderer. A completed email login is thus indistinguishable downstream.
999
999
  const onAuthResult = (payload) => {
1000
1000
  if (payload && payload.token) {
1001
+ // Account isolation: if a DIFFERENT user is signing in, drop the previous
1002
+ // account's cloud/remote teams so they don't leak onto this account. The
1003
+ // login below re-pulls this account's own teams fresh. Local machine
1004
+ // teams (localhost) are kept — they belong to the device.
1005
+ try {
1006
+ const prevUid = String((readGlobalConfig(GLOBAL_JSON)?.desktopAuth?.userId) || "");
1007
+ const nextUid = payload.userId != null ? String(payload.userId) : "";
1008
+ if (prevUid && nextUid && prevUid !== nextUid) {
1009
+ require("./backends/local-teams").purgeAccountTeams().catch(() => {});
1010
+ }
1011
+ } catch {}
1001
1012
  saveDesktopAuth(payload);
1002
1013
  // Now that we have a real owner-bound login token, report this
1003
1014
  // machine to the cloud (best-effort; safe to call repeatedly —
@@ -1067,6 +1078,8 @@ electronApp.whenReady().then(async () => {
1067
1078
  __ipcMainAuth.handle("auth:logout", () => {
1068
1079
  try {
1069
1080
  updateGlobalConfig(GLOBAL_JSON, (c) => { delete c.desktopAuth; return c; });
1081
+ // Drop this account's cloud/remote teams on logout (local machines stay).
1082
+ require("./backends/local-teams").purgeAccountTeams().catch(() => {});
1070
1083
  log.info("[auth] desktop login cleared (explicit logout)");
1071
1084
  } catch (e) { log.warn(`[auth] logout clear failed: ${e.message}`); }
1072
1085
  return { ok: true };
@@ -269,6 +269,16 @@ function setPublicFlag(on) {
269
269
  return f.public;
270
270
  }
271
271
 
272
+ // 「容器内使用 Docker」开关(desktop-flags.json 的 dood 字段 = Docker-outside-of-Docker)。
273
+ // 开启后 runContainer 把宿主 dockerd 的 /var/run/docker.sock 挂进容器,并把 docker CLI 装进
274
+ // 容器持久卷,容器内 agent 就能跑 docker。只影响 Windows/WSL(+mac colima)容器路径。
275
+ function isDood() { return !!readFlags().dood; }
276
+ function setDood(on) {
277
+ const f = readFlags(); f.dood = !!on;
278
+ try { fs.mkdirSync(path.dirname(FLAGS_FILE), { recursive: true }); fs.writeFileSync(FLAGS_FILE, JSON.stringify(f, null, 2)); } catch (e) { console.warn(`[cicy-code-sidecar] setDood write failed: ${e.message}`); }
279
+ return f.dood;
280
+ }
281
+
272
282
  // 「Cloudflare Tunnel」配置持久化(同 desktop-flags.json 的 cft 字段):
273
283
  // { enabled, token, host }。enabled 且 token 非空时,start() 注入 CICY_CFT_TOKEN/
274
284
  // CICY_CFT_HOST 环境变量 —— cicy-code 据此跑一条命名隧道(固定域名)。host 是对外
@@ -565,4 +575,4 @@ async function update({ logPath, port = DEFAULT_PORT, emit } = {}) {
565
575
  }
566
576
  }
567
577
 
568
- module.exports = { start, stop, restart, update, probeExisting, clearNpxCache, isUpdating, isBusy, ensureEnv, ensureNode, isPublic, setPublicFlag, getCft, setCft, cftEnv };
578
+ module.exports = { start, stop, restart, update, probeExisting, clearNpxCache, isUpdating, isBusy, ensureEnv, ensureNode, isPublic, setPublicFlag, isDood, setDood, getCft, setCft, cftEnv };
@@ -143,7 +143,15 @@ async function start({ port = 8008, logPath = null, emit, version = null } = {})
143
143
  };
144
144
  // --helper removed : boot cicy-code in normal mode (full tmux-based
145
145
  // multi-agent), not the single headless 团队助手.
146
- const child = spawn(exe, ["--desktop"], { stdio, detached: true, windowsHide: true, env });
146
+ const args = ["--desktop"];
147
+ // Host role: if a zero-trust gateway is provisioned (~/cicy-ai/db/gateway.json
148
+ // with url+token), boot with --gateway so this machine dials OUT and becomes
149
+ // reachable through the gateway (no inbound port). Absent → plain local boot.
150
+ try {
151
+ const gwPath = require("path").join(require("os").homedir(), "cicy-ai", "db", "gateway.json");
152
+ if (require("fs").existsSync(gwPath)) args.push("--gateway");
153
+ } catch { /* best-effort */ }
154
+ const child = spawn(exe, args, { stdio, detached: true, windowsHide: true, env });
147
155
  child.unref();
148
156
  try { fs.writeFileSync(PID_FILE, String(child.pid)); } catch {}
149
157
  console.log(`[native-sidecar] spawned ${exe} --desktop pid=${child.pid} port=${port} log=${logPath || "(none)"}`);
@@ -560,6 +560,32 @@ async function allDrivesMountArg() {
560
560
  } catch (e) { log.warn(`[wsl-docker] enumerate drives failed: ${e.message}`); return ""; }
561
561
  }
562
562
 
563
+ // Docker-outside-of-Docker: 把 WSL 里 dockerd 的 socket 挂进容器,容器内 agent 就能直接跑
564
+ // docker —— 复用 WSL 那个 dockerd,起的是**兄弟容器**(非嵌套),所以兄弟容器里 `-v` 的路径
565
+ // 是 WSL 宿主视角、不是 cicy-code 容器内路径。socket 通常 root:docker(或 root:root)660,
566
+ // 给容器进程 `--group-add <socket 的 gid>` 让非 root 的 cicy 用户也能访问。
567
+ // 同时把 WSL 自带的 docker 客户端(静态 Go 二进制,/usr/bin/docker)只读挂进容器 PATH —— 这样
568
+ // 容器里立刻有 `docker` 命令可用,**不用下载**(之前下 70MB static tarball 会让 UI 卡在"保存中")。
569
+ // socket 不在(dockerd 没起)→ 返回 ""(best-effort,不挡容器启动)。挂载在 `docker run` 时定死,
570
+ // 已在跑的容器要 recreate 才带上。
571
+ async function dockerSockMountArg() {
572
+ const SOCK = "/var/run/docker.sock";
573
+ try { await wslRun(`test -S ${SOCK}`, { timeout: 8000 }); } catch { return ""; } // socket 不存在
574
+ let groupAdd = "";
575
+ try {
576
+ const { stdout } = await wslRun(`stat -c '%g' ${SOCK} 2>/dev/null`, { timeout: 8000 });
577
+ const gid = String(stdout || "").trim();
578
+ if (/^\d+$/.test(gid)) groupAdd = ` --group-add ${gid}`;
579
+ } catch {}
580
+ let cliMount = "";
581
+ try {
582
+ const { stdout } = await wslRun(`command -v docker 2>/dev/null`, { timeout: 8000 });
583
+ const p = String(stdout || "").trim();
584
+ if (p) cliMount = ` -v ${p}:/usr/local/bin/docker:ro`;
585
+ } catch {}
586
+ return `-v ${SOCK}:${SOCK}${cliMount}${groupAdd}`;
587
+ }
588
+
563
589
  // extraPorts → additional `-p 127.0.0.1:<p>:<p>` mappings (host=container, loopback
564
590
  // only). For container-internal agent services the user wants reachable from
565
591
  // Windows. Each adds one docker-proxy, so keep the list small. 8008/主端口自动排除。
@@ -575,7 +601,7 @@ function publishArgs(port, extraPorts = []) {
575
601
  return args;
576
602
  }
577
603
 
578
- async function runContainer({ port = 8008, container = "cicy-code-docker", volume = "cicy-team-8008", env = {}, extraPorts = [], emit } = {}) {
604
+ async function runContainer({ port = 8008, container = "cicy-code-docker", volume = "cicy-team-8008", env = {}, extraPorts = [], dockerSock = false, emit } = {}) {
579
605
  // 每次容器"启动"(含已在跑被 adopt)都确保桌面快捷方式存在 —— 不存在就建,坏了就修。
580
606
  if (await probeHealth(port)) { ensureDesktopShortcut(volume, port).catch(() => {}); return { adopted: true }; }
581
607
  // Replace any stale same-named container.
@@ -599,7 +625,12 @@ async function runContainer({ port = 8008, container = "cicy-code-docker", volum
599
625
  // 已删——docker 默认每端口起一个 userland-proxy 进程 → 2000 进程,docker run 卡死/吃内存/
600
626
  // 偶发失败(实测)。容器内 agent 服务需要从 Windows 直达时再按需单独暴露。
601
627
  const drivesArg = await allDrivesMountArg(); // 动态:有几个盘挂几个
602
- const cmd = `docker run -d --name ${container} --restart unless-stopped --dns 223.5.5.5 --dns 8.8.8.8 ${publishArgs(port, extraPorts)} -e CICY_PUBLIC=1 -v ${volume}:/home/cicy ${projectsMountArg()} ${drivesArg} ${envArgs} ${IMAGE}`;
628
+ // DooD:开关开时挂 WSL docker.sock。跟 cftEnv 一样内部读 flag(与显式 opt 取或),
629
+ // 这样任何 recreate 调用方式(带不带 appOpts)都能生效。
630
+ let doodOn = dockerSock;
631
+ if (!doodOn) { try { doodOn = require("./cicy-code").isDood(); } catch {} }
632
+ const sockArg = doodOn ? await dockerSockMountArg() : "";
633
+ const cmd = `docker run -d --name ${container} --restart unless-stopped --dns 223.5.5.5 --dns 8.8.8.8 ${publishArgs(port, extraPorts)} -e CICY_PUBLIC=1 -v ${volume}:/home/cicy ${projectsMountArg()} ${drivesArg} ${sockArg} ${envArgs} ${IMAGE}`;
603
634
  emit && emit({ phase: "container", status: "running", message: `$ ${cmd.length > 220 ? cmd.slice(0, 220) + " …" : cmd}` });
604
635
  await wslRun(cmd, { timeout: 60000 }); // 失败时 err.stderr 带 docker 真错误 → _bootstrap 的 errTail 显示
605
636
  ensureDesktopShortcut(volume, port).catch(() => {});
@@ -764,7 +795,7 @@ async function bootstrap(opts = {}) {
764
795
  // 注意: 默认容器/卷名保持 cicy-team / cicy-code-docker(回退实测"现在不行了"的改动)。
765
796
  // live 路径(docker:app-bootstrap)始终传显式 APP_*(cicy-team-8008)名,不靠这里的默认;
766
797
  // 改默认会让既有 cicy-team 卷的装机对不上 → 退回原值。
767
- async function _bootstrap({ onProgress, port = 8008, container = "cicy-code-docker", volume = "cicy-team", env = {}, extraPorts = [] } = {}) {
798
+ async function _bootstrap({ onProgress, port = 8008, container = "cicy-code-docker", volume = "cicy-team", env = {}, extraPorts = [], dockerSock = false } = {}) {
768
799
  const emit = (ev) => { try { onProgress && onProgress(ev); } catch {} };
769
800
 
770
801
  // Structured, PERSISTED trace of the whole run (electron-log → main.log) so a
@@ -875,7 +906,7 @@ async function _bootstrap({ onProgress, port = 8008, container = "cicy-code-dock
875
906
  begin("run-container");
876
907
  if (!(await probeHealth(port))) {
877
908
  emit({ phase: "container", status: "running", message: "启动 cicy-code 服务…" });
878
- try { await runContainer({ port, container, volume, env, extraPorts, emit }); }
909
+ try { await runContainer({ port, container, volume, env, extraPorts, dockerSock, emit }); }
879
910
  catch (e) { fail("container_start_failed", e.message); emit({ phase: "container", status: "error", message: `服务启动失败:${e.message}(点重试)${errTail(e)}` }); finish(false, "container_start_failed"); return { ok: false, reason: "container_start_failed" }; }
880
911
  done();
881
912
  } else done(true);
@@ -1028,14 +1059,14 @@ async function dockerRestart({ container = "cicy-code-docker-8008" } = {}) {
1028
1059
  // 重建容器:docker rm -f 旧容器 + docker run 新容器(用新 env,如新的 docker team 网关
1029
1060
  // key)。**保留 volume**(数据/api_token/deviceId 不丢),只是换掉容器本身 + env。
1030
1061
  // 破坏性(短暂中断 + 换 key)→ 调用方要 confirm。
1031
- async function recreate({ onProgress, port = 8008, container = "cicy-code-docker-8008", volume = "cicy-team-8008", env = {}, extraPorts = [] } = {}) {
1062
+ async function recreate({ onProgress, port = 8008, container = "cicy-code-docker-8008", volume = "cicy-team-8008", env = {}, extraPorts = [], dockerSock = false } = {}) {
1032
1063
  const emit = (ev) => { try { onProgress && onProgress(ev); } catch {} };
1033
1064
  // 重建 = 用最新镜像重建。OSS 有更新版先刷新(非破坏性,不删发行版/volume),再 rm + run。
1034
1065
  try { await ensureFreshImage({ emit }); } catch (e) { emit({ phase: "image", status: "running", message: `镜像刷新跳过(${e.message}),用现有镜像重建` }); }
1035
1066
  // 强删占用该端口的**任何**容器(含老名字 cicy-code-docker)+ 目标容器 —— 否则
1036
1067
  // runContainer 开头的 probeHealth 看到旧容器还健康会 adopt 它、不重建,key 就换不了。
1037
1068
  try { await wslRun(`docker ps -aq --filter publish=${port} | xargs -r docker rm -f 2>/dev/null; docker rm -f ${container} 2>/dev/null; true`, { timeout: 30000 }); } catch {}
1038
- const r = await runContainer({ port, container, volume, env, extraPorts, emit });
1069
+ const r = await runContainer({ port, container, volume, env, extraPorts, dockerSock, emit });
1039
1070
  try { await ensureDesktopShortcut(volume, port); } catch {}
1040
1071
  // 等 :8008 真正起来再返回——否则 docker run 一返回卡片就探测「没运行」,显示「启动」让
1041
1072
  // 用户再点一下(实测:端口保存后重建,drawer 关了还要手动点启动)。
@@ -1064,7 +1095,7 @@ async function upgrade({ onProgress, port = 8008, container = "cicy-code-docker"
1064
1095
  try { await stop({ container }); } catch {}
1065
1096
  try { await unregisterDistro(); } catch {}
1066
1097
  // Reuse the robust one-shot install flow (download → import → dockerd → run).
1067
- return await _bootstrap({ onProgress, port, container, volume, env });
1098
+ return await _bootstrap({ onProgress, port, container, volume, env, dockerSock });
1068
1099
  }
1069
1100
 
1070
1101
  // 容器里有没有注入网关 key(reconcile 自愈用):printenv 看 CICY_AI_GATEWAY_LLM_API_KEY。
@@ -2016,6 +2016,7 @@ function DockerCard({ dockerTeam, cloudTitle, cloudCode, onOpen, onRename, onRef
2016
2016
  const [busy, setBusy] = useState(""); // "" | bootstrap | restart | stop | upgrade | probe
2017
2017
  const [menuOpen, setMenuOpen] = useState(false);
2018
2018
  const [cftOpen, setCftOpen] = useState(false); // Cloudflare 隧道 modal(卡片层,菜单外)
2019
+ const [doodOpen, setDoodOpen] = useState(false); // 容器内使用 Docker(DooD)modal(卡片层,菜单外)
2019
2020
  const [confirmRecreate, setConfirmRecreate] = useState(false); // 重建容器 in-app 确认弹窗(不用 native confirm)
2020
2021
  const [portsOpen, setPortsOpen] = useState(false); // 端口设置 modal
2021
2022
  const [portList, setPortList] = useState([]); // 编辑中的额外端口(字符串数组,便于输入)
@@ -2411,6 +2412,11 @@ function DockerCard({ dockerTeam, cloudTitle, cloudCode, onOpen, onRename, onRef
2411
2412
  onClick={(e) => { e.stopPropagation(); setMenuOpen(false); window.cicy?.docker?.openDir?.("projects"); }}>
2412
2413
  {tr("docker.openProjectsDir", "打开项目目录")}
2413
2414
  </button>
2415
+ <button type="button" data-id="DockerCard-dood" className="bcard__menu-item"
2416
+ title={tr("dood.hint", "把宿主 Docker + docker 客户端挂进容器,容器内 agent 能直接跑 docker(切换会重建容器,秒生效、无需下载)")}
2417
+ onClick={(e) => { e.stopPropagation(); setMenuOpen(false); setDoodOpen(true); }}>
2418
+ {tr("dood.menu", "容器内使用 Docker")}
2419
+ </button>
2414
2420
  <button type="button" data-id="DockerCard-billing" className="bcard__menu-item"
2415
2421
  onClick={() => { setMenuOpen(false); openCloudPage((cloudCode || dockerTeam?.cloud_team_id) ? `?team=${encodeURIComponent(cloudCode || dockerTeam.cloud_team_id)}` : "?view=usage"); }}>
2416
2422
  {tr("docker.billing", "帐单")}
@@ -2487,6 +2493,7 @@ function DockerCard({ dockerTeam, cloudTitle, cloudCode, onOpen, onRename, onRef
2487
2493
  <span>{ctaLabel}</span>
2488
2494
  </button>
2489
2495
  <CftModal open={cftOpen} onClose={() => setCftOpen(false)} toastId="docker-op" />
2496
+ <DoodModal open={doodOpen} onClose={() => setDoodOpen(false)} toastId="docker-op" />
2490
2497
  {confirmRecreate && createPortal(
2491
2498
  <div data-id="DockerCard-recreate-modal"
2492
2499
  style={{ position: "fixed", inset: 0, zIndex: 9999, background: "rgba(0,0,0,0.55)", display: "flex", alignItems: "center", justifyContent: "center" }}
@@ -3529,6 +3536,70 @@ function CftModal({ open, onClose, toastId = "cft-op" }) {
3529
3536
  </>
3530
3537
  );
3531
3538
  }
3539
+ // 容器内使用 Docker(Docker-outside-of-Docker)modal —— 单 checkbox,像 CftModal 一样渲染在
3540
+ // 卡片层(菜单外)。开启 → sidecar.setDood → 重建容器挂 docker.sock + 把 docker CLI 装进容器
3541
+ // 持久卷,下载进度实时显示在弹窗里。DockerCard(Windows 容器)用。
3542
+ function DoodModal({ open, onClose, toastId = "dood-op" }) {
3543
+ const [busy, setBusy] = useState(false);
3544
+ const [err, setErr] = useState("");
3545
+ const [on, setOn] = useState(false);
3546
+ const [line, setLine] = useState("");
3547
+ useEffect(() => {
3548
+ if (!open || !window.cicy?.sidecar?.getDood) return;
3549
+ setErr(""); setLine("");
3550
+ window.cicy.sidecar.getDood().then((r) => setOn(!!r?.dood)).catch(() => {});
3551
+ }, [open]);
3552
+ // 把重建/下载进度实时显示在弹窗(installDockerCli 的每行输出经 op-progress 推来)
3553
+ useEffect(() => {
3554
+ if (!open || !busy || !window.cicy?.sidecar?.onOpProgress) return;
3555
+ return window.cicy.sidecar.onOpProgress((ev) => { if (ev?.message) setLine(String(ev.message)); });
3556
+ }, [open, busy]);
3557
+ const save = async () => {
3558
+ if (busy) return;
3559
+ setBusy(true); setErr(""); setLine("");
3560
+ toast.show({ id: toastId, message: on ? tr("dood.applying", "开启容器 Docker 访问,重建容器中…") : tr("dood.disabling", "关闭中…"), status: "running", progress: undefined });
3561
+ try {
3562
+ const r = await window.cicy.sidecar.setDood(on);
3563
+ if (r?.ok) {
3564
+ onClose && onClose();
3565
+ toast.show({ id: toastId, message: on ? tr("dood.onDone", "容器 Docker 访问已开启") : tr("dood.offDone", "容器 Docker 访问已关闭"), status: "done", ttl: 4000 });
3566
+ } else {
3567
+ setErr(r?.error || tr("dood.failed", "设置失败"));
3568
+ toast.show({ id: toastId, message: tr("dood.failed", "设置失败") + (r?.error ? `: ${r.error}` : ""), status: "error", ttl: 6000 });
3569
+ }
3570
+ } catch (e) { setErr(e?.message || String(e)); toast.show({ id: toastId, message: tr("dood.failed", "设置失败") + `: ${e?.message || e}`, status: "error", ttl: 6000 }); }
3571
+ finally { setBusy(false); }
3572
+ };
3573
+ const setOpenX = (v) => { if (!v) onClose && onClose(); };
3574
+ if (!open) return null;
3575
+ return (
3576
+ <>
3577
+ {createPortal(
3578
+ <div data-id="dood-modal"
3579
+ style={{ position: "fixed", inset: 0, zIndex: 66, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(0,0,0,.5)" }}
3580
+ onMouseDown={(e) => { if (!busy && e.target === e.currentTarget) setOpenX(false); }}>
3581
+ <div onClick={(e) => e.stopPropagation()}
3582
+ style={{ width: 440, maxWidth: "92vw", background: "var(--card, #1b1d22)", border: "1px solid var(--border, #2c2f36)", borderRadius: 14, padding: 22, boxShadow: "0 20px 60px rgba(0,0,0,.5)" }}>
3583
+ <div style={{ fontSize: 16, fontWeight: 700, marginBottom: 4 }}>{tr("dood.title", "容器内使用 Docker")}</div>
3584
+ <div style={{ fontSize: 12, opacity: .6, marginBottom: 16 }}>{tr("dood.subtitle", "把宿主 Docker + docker 客户端挂进容器,容器内 agent 就能直接跑 docker(秒生效,无需下载)")}</div>
3585
+ <label data-id="dood-toggle-row" style={{ display: "flex", alignItems: "center", gap: 10, cursor: busy ? "default" : "pointer", marginBottom: 12 }}>
3586
+ <input type="checkbox" data-id="dood-toggle" checked={on} disabled={busy}
3587
+ onChange={(e) => setOn(e.target.checked)} />
3588
+ <span style={{ fontSize: 14, fontWeight: 600 }}>{tr("dood.enable", "允许容器内使用 Docker")}</span>
3589
+ </label>
3590
+ {busy && line && <div data-id="dood-progress" style={{ fontSize: 11, opacity: .7, fontFamily: "var(--mono)", wordBreak: "break-all", margin: "0 0 8px", maxHeight: 60, overflow: "hidden" }}>{line}</div>}
3591
+ {err && <div className="error" style={{ marginTop: 8, fontSize: 12 }}>{err}</div>}
3592
+ <div className="modal-actions">
3593
+ <button type="button" className="btn-ghost" disabled={busy} onClick={() => setOpenX(false)}>{tr("common.cancel", "取消")}</button>
3594
+ <button type="button" className="btn-primary" data-id="dood-save" disabled={busy} onClick={save}>{busy ? tr("common.saving", "保存中…") : tr("common.save", "保存")}</button>
3595
+ </div>
3596
+ </div>
3597
+ </div>,
3598
+ document.body,
3599
+ )}
3600
+ </>
3601
+ );
3602
+ }
3532
3603
  function TeamAvatar({ avatar, name, teamId, onChanged, size = 34 }) {
3533
3604
  const fileRef = useRef(null);
3534
3605
  const initial = ((name || "?").trim()[0] || "?").toUpperCase();