From 0cd7760fa8efb7b29619e932b99053a69a56a27c Mon Sep 17 00:00:00 2001 From: unix2dos <22365670+unix2dos@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:38:10 +0800 Subject: [PATCH 1/8] feat: add user-owned VPS deployment path --- CONTEXT.md | 7 +- PRODUCT.md | 183 +++-------- README.md | 6 + cmd/carry/engine.go | 52 ++- cmd/carry/main.go | 91 +++++- cmd/carry/main_test.go | 29 +- cmd/carry/provider.go | 20 ++ cmd/carry/store.go | 38 ++- cmd/carry/vercel.go | 3 + cmd/carry/vps.go | 295 ++++++++++++++++++ cmd/carry/vps_test.go | 219 +++++++++++++ .../0003-no-automatic-charges-by-default.md | 2 + ...005-language-neutral-container-delivery.md | 2 + ...0006-first-deployment-on-user-owned-vps.md | 5 + 14 files changed, 777 insertions(+), 175 deletions(-) create mode 100644 cmd/carry/vps.go create mode 100644 cmd/carry/vps_test.go create mode 100644 docs/adr/0006-first-deployment-on-user-owned-vps.md diff --git a/CONTEXT.md b/CONTEXT.md index f4097cb..e46629c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,6 +1,6 @@ # 个人开源部署项目 -在用户本地提供可视化管理服务,帮助用户已有的编码 Agent 部署、检查和维护其跨平台应用资源。这里记录已明确的领域用语;技术选型见 [架构决策](docs/adr/),当前范围见 [PRODUCT.md](PRODUCT.md)。 +在用户本地提供管理服务,帮助用户已有的编码 Agent 将应用部署到自有服务器或云平台,并核对发布结果。这里记录已明确的领域用语;技术选型见 [架构决策](docs/adr/),当前范围见 [PRODUCT.md](PRODUCT.md)。 ## Language @@ -10,6 +10,9 @@ _Avoid_: 用“AI 应用”统称所有受管应用 **资源账号**:用户持有的第三方基础设施服务账号,应用及相关云资源由这些账号承载。 _Avoid_: 将第三方资源账号授权与作者统一账号登录混用 +**部署目标**:用户授权 Carry 在其上发布受管应用的服务器或云平台项目。管理应用的发布不等于接管整台服务器或整个资源账号。 +_Avoid_: 将应用发布权限称为主机或账号的无限管理权限 + **跨平台组合**:同一个受管应用使用不同平台提供的计算、数据库、存储等服务,并由本项目协调它们之间的连接。 _Avoid_: 将“能够选择一个部署平台”称为“跨平台组合” @@ -31,7 +34,7 @@ _Avoid_: 将外部编码 Agent 称为本项目内置的聊天助手 **外接资源**:应用通过连接信息使用、但未交由本工具管理配置和生命周期的既有资源。 _Avoid_: 将配置连接信息称为接管资源 -**受管资源**:本工具负责管理其配置和生命周期的云资源,可以由工具创建,也可以由用户明确接管。 +**受管资源**:本工具负责管理其配置和生命周期的用户资源,可以由工具创建,也可以由用户明确接管;在 VPS 路径中,受管范围先限于授权应用的部署单元。 _Avoid_: 将受管资源与作者拥有的资源混用 **资源接管**:用户授权工具通过供应商管理接口,把既有资源纳入所支持的管理范围和状态记录的过程。资源仍运行在原供应商处,账号及费用归属不变;SQL 连接凭证并不自动授予供应商资源管理权限。 diff --git a/PRODUCT.md b/PRODUCT.md index 5d21561..281ff6e 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -1,166 +1,59 @@ -# Carry · 产品范围 +# Carry · 产品设计提案 -项目名称已由用户确认为 **Carry**(2026-09-18;此前名为 Ship、UpOK)。新命令、源码和安装目录使用 `carry`,新用户状态默认位于 `~/.carry`;已有 `~/.ship` 与旧配置目录继续读取。历史验收记录保留当时的命名。源码与 macOS arm64 预编译包在 `unix2dos/carry` 发布。 +状态:2026-09-25 访谈后已确认的下一版方向,正在实施。这里描述目标与验收边界;[当前 Alpha](docs/ALPHA.md)仍只管理已有的 Vercel / Railway 应用,可选关联 Neon,不能创建云资源或部署到 VPS。[首次使用说明](docs/FIRST-TRY.md)按当前可用版本编写。 -当前推进到安装验收:独立安装目录和空白本地状态下,复用现有官方登录,已完成资源登记、实际发布、跨进程核对及数据库数据保留;支持 macOS arm64 的预编译安装器会配置 PATH、注册 Skill;无云请求的安装检查已提供。真正的新用户 OAuth、独立外部试用仍未完成。正式 Free 验证因当前测试账号仍是 Trial 暂停,外部试用仍待独立验证。[验收记录](validation/results/upok-install-report.json) +## 要解决的问题 -状态:2026-09-17 已完成可实际使用的内部试验版 CLI、网页与配套 Skill,关联已有个人测试资源并通过真实发布与接续验证。它还不覆盖完整首版范围,正式 Free、自动资源创建、真实云间迁移和独立用户复现仍待完成。[内部版说明](docs/ALPHA.md) / [内部版验收](validation/results/internal-alpha-report.json)。此前 29 组本地容器检查和云端样例验证分别见[本地结果](validation/RESULTS.md)与[云端结果](validation/CLOUD-RESULTS.md)。 +一个人能写出应用,却经常卡在首次上线、更新、查错和保住数据。Carry 帮他把**自己的代码发布到自己的运行资源**,记录每次操作的目标与结果,并在失败或结果不明时核对现状,再决定继续或回退。 -## 产品定位 +优先级是作者真实自用,其次才是外部开发者采用和公开作品展示。首个真实项目是 [Loop](https://github.com/unix2dos/loop),首个目标主机是作者已经购买的 DMIT VPS。这个实例计划以 `loop.liuvv.com` 提供匿名访问;域名是本次验证样例,不是 Carry 为其他用户提供的公共子域名。 -一个在用户本地运行、带可视化界面的开源部署管理工具,让用户已有的编码 Agent 连接并管理用户自己的跨平台资源。 +## 产品定位与取舍 -核心功能不依赖作者运营的在线后端。这里的目标是本地管理与用户自持资源;部署仍通过网络调用用户选用的云平台,不使用“完全去中心化”来替代具体的依赖边界。 +Carry 是本地运行的部署与结果核对工具。用户已有的编码 Agent 可以调用它;没有 Agent 时,同一操作仍可从 CLI 发起。应用运行在用户自己的服务器或云账号中,凭证与操作记录留在用户控制的环境里。Carry 不运营集中式控制面。 -项目通过可运行的软件、设计记录和验证证据展示实现与取舍。 +第一条完整路径聚焦**已有 Linux VPS 上的一个 Web 应用**:从已有镜像或 Dockerfile 到 HTTPS 地址,再完成一次更新、一次故障恢复和实际业务检查。免费额度查询可作为已有云平台的辅助能力,不再作为产品承诺和平台选择的中心:额度、资格和用途由供应商决定,例如 Railway 的 Trial 与每月 Free 是两种条件,Vercel Hobby 限个人非商业用途。[Railway 计划](https://docs.railway.com/pricing/plans) · [Vercel Hobby](https://vercel.com/docs/plans/hobby) 已购服务器的固定费用是用户明确选择的成本;Carry 不替用户静默开通额外付费资源。 -首批目标用户为已经使用编码 Agent、能够开发项目但不熟悉云部署的开发者。首版围绕这一人群验证安装、首次部署和后续管理体验。 +这不是再做一个功能齐全的服务器面板。Coolify 和 Dokploy 已覆盖应用、数据库及备份,Coolify 也提供 CLI;Kamal 已覆盖经 SSH 发布容器。Carry 需要验证的差别是:在**已有服务的主机**上先说明改动范围,用明确授权执行,记录镜像与配置,发布后核对真实结果,未知结果先查再重试,并保留可用的回退点。[Coolify CLI](https://coolify.io/docs/cli/deploy-applications) · [Coolify 数据库备份](https://coolify.io/docs/databases/backups) · [Dokploy 数据库](https://docs.dokploy.com/docs/core/databases) · [Kamal](https://kamal-deploy.org/docs/installation/) -## 已确认的边界 +这是一条差异化假设,不是已获用户需求验证。先在 Loop 上证明它确实比直接操作 Docker 或现有面板省心,再邀请外部开发者复现。 -- 管理服务运行在用户自己的电脑上,网页展示设置、资源、部署状态和日志。 -- 接入用户已有的编码 Agent,首版不内置聊天 Agent。 -- 提供一个本地工具,以 CLI 暴露已支持的明确操作,薄 Skill 指导 Agent 调用,本地网页承接引导和可视化管理;各入口复用执行、状态与授权检查逻辑。 -- Agent 不可用时,用户仍能直接执行已支持的部署、更新、查询与恢复操作;复杂分析可以由人工完成,不要求每项操作都有网页按钮。 -- 首版从 GitHub Releases 分发 macOS arm64 预编译工具,安装后可直接运行 `carry`,并注册 Codex / Claude Code Skill;网页按需通过 `carry serve --open` 启动,再由 Agent 引导完成供应商授权与资源关联。 -- 网页提供账号接入引导、项目和资源查看、脱敏日志,以及对已有配置执行更新、核对结果后的重试等少量常用操作;首次接入与复杂调整主要由 Agent 或 CLI 完成。 -- 日常网页保持轻量:默认显示应用、地址、最近状态和常用操作,账号设置、日志与资源详情按需展开;流程讲解、模拟故障和自检控件不进入正常使用界面。 -- 首版优先支持在用户本机访问已授权项目目录并执行 CLI 的 Agent,先完整验收一种接入,再逐步扩展;工具执行位置在本机即可,模型服务可以在云端。远程执行工具的 Agent 接入后置。 -- 关闭网页不取消仍在本地服务中执行的任务;退出本地执行进程后,尚未执行的本地步骤暂停,已提交的云端任务由供应商处理。重启后先核对实际结果,再提示用户继续或重试,避免重复创建资源;首版不提供退出工具后仍常驻协调任务的后台服务。 -- 应用、数据库等资源运行在用户自己的云账号里,作者不运营集中式用户资源管理实例。 -- 首版不要求登录作者账号,本地持有的云凭证、项目状态和操作记录由用户掌控;部署与运维直接使用用户授权的供应商接口。 -- 访问地址默认可使用服务商域名,支持用户绑定自己的域名;首版不运营面向用户的公共子域名分配服务。作者自己的域名可用于官网、文档、下载和演示,不能成为用户核心操作的必需依赖。 -- 优先接入已有 Web/API 项目,尽量少改业务代码,不要求从专用模板起步。 -- 首版不限定为 JS/TS;Go、Python、Node、Rust 等普通后端同样重要,语言范围可继续扩展。 -- 语言不设白名单,以目标支持的标准 Linux 容器镜像或 Dockerfile 为通用接入入口;缺失构建配置时,由现有工具或 Agent 辅助生成,并以实际构建和运行验证。 -- 默认由用户目标平台构建,前提是符合免费约束;已有镜像直接复用。首版不要求用户先安装本地容器环境,作者不提供集中式构建服务器。 -- 平台选择重点考虑现有应用兼容性和后续迁移性,不能只按免费额度排名。默认不自动收费的约束保持不变。 -- 首版完成一条计算加数据库的跨平台组合,覆盖创建、连接、部署、更新和故障恢复。 -- 主要管理工具创建的资源;已有数据库等资源先通过连接信息使用,完整接管后置。 -- 默认免费方案不自动收费,额度耗尽可以限制或停用;付费能力必须由用户主动选择。 -- 费用边界须由资源平台的计划或有效限制支撑,不能依赖本地工具一直在线。 -- 首版维护由用户发起,持续巡检和自动修复后置。 -- 用可公开的普通 Web/API + PostgreSQL 示例验收,便于外部开发者复现;示例不是接入其他项目的必选模板。 -- 用户先授权项目内常规操作,删除数据、启用付费等高影响操作单独确认。 -- 接受免费方案休眠后的首次访问等待,并在方案中明示;额度耗尽允许限制或停用。 -- 已确认迁移范围:示例应用的业务表和数据迁往另一个兼容 PostgreSQL,允许计划停机和暂停写入,保留源库并验证目标功能。 +## 第一版交付边界 -## 首版用户路径草案 +- **应用交付**:优先复用现成 OCI 镜像;否则使用项目的 Dockerfile 构建。单机运行采用 Docker Compose。资源紧张时在本机或 CI 构建目标架构镜像,让 VPS 只负责拉取和运行。暂不为每种语言实现原生进程发布器,也不自建构建平台。 +- **主机接入**:通过用户已有 SSH 授权接入一台 Linux 主机;发布前读取系统架构、CPU/内存/磁盘、已有监听端口、容器运行环境及将影响的网络规则。首版不购买服务器、不重装系统、不接管无关服务。 +- **公开访问**:用户自有域名、HTTPS、反向代理;只对外开放 Web 必需端口,应用与管理入口分开。部署目标和现有服务端口冲突时停止并说明原因。 +- **持久数据**:应用声明的数据目录使用持久卷,更新容器不删除它。发布前保留上一个可运行镜像;首版回退只切换应用镜像,仍使用当前配置和密钥,不回退业务数据。 +- **操作流程**:预检 → 展示变更及影响 → 用户授权 → 发布 → 读取服务状态与日志 → HTTP 检查 → 应用自身的业务检查。提交后失去响应时先查询当前镜像、容器和服务状态,不直接再次执行发布。 +- **费用边界**:第一版默认只使用已购 VPS 和用户明确选择的免费模型。模型额度耗尽时暂停相关功能;没有付费模型或付费云资源的自动回退。Carry 的部署、状态、日志与恢复操作本身不依赖模型服务。 -1. 安装一个本地工具,通过一条启动命令打开管理页面,按引导连接现有编码 Agent。 -2. 用户选择现有项目,并授权自己的资源账号。 -3. 工具检查项目运行要求、账号计划、剩余额度及服务组合是否可用。 -4. 展示将创建的资源、适用的免费条件及已有资源连接方式。 -5. 按确定的操作授权规则创建资源,将构建交给用户目标平台并部署应用;已有镜像直接使用。 -6. 返回访问地址及验证结果,网页可查看运行状态和日志。 -7. 用户后续要求更新或排障,Agent 调用本地工具执行并解释结果;Agent 不可用时,用户仍可通过 CLI 直接执行已支持的明确操作。 -8. 部署中途退出本地执行进程后,重新打开工具可查看中断位置;先核对已经提交给云端的操作,再继续未完成的步骤。未知结果需要保持可见,不能直接当作失败重做。 +原生 `systemd` 发布可以用于个别不适合容器或迁移风险过高的现有服务,但不是第一版的第二套通用部署引擎。单台 VPS 不需要 Kubernetes、多节点调度或一个常驻的 Carry 服务器端控制面。 -以上路径对应已确认的产品方向,详细操作分类和验收条目如下,仍以草案方式供用户审阅。 +## 首个真实验证:Loop → DMIT -### 可体验的交互草图 +当前 Loop 是本地单用户 Node.js 工具,已有 [Dockerfile](https://github.com/unix2dos/loop/blob/main/Dockerfile)、`/healthz`,没有数据库或对象存储。现有公开模式没有访客身份:所有人能列出并读取全部轨迹,也能取得提交任务所用的同源令牌。镜像把运行记录放在 `/tmp`,换容器后无法保留。[请求处理](https://github.com/unix2dos/loop/blob/main/src/server.ts) · [镜像配置](https://github.com/unix2dos/loop/blob/main/Dockerfile) -[打开单文件原型](docs/prototypes/local-deploy-flow.prototype.html),或在本地双击该 HTML 文件;无须安装依赖。默认展示极简应用面板:应用状态、示例访问地址和打开应用、查看日志、发布更新三个常用入口;账号与资源详情按需查看。默认视图预置一个示例应用,发布更新由计时器模拟完成,不连接真实账号、数据库或应用。 +因此公开部署先满足这些应用条件,再检验 Carry 的发布能力: -原流程评审移到同一预览地址的 `?review=1` 入口,保留首次部署、Agent 不可用时的日常更新,以及本地工具退出后的状态核对与继续操作。网页授权、CLI 调用、云端完成和网络故障仍由评审按钮模拟;两个入口共享状态转换逻辑,刷新页面均会重置。 +1. 只挂载可公开的示例 Markdown;不把作者私人笔记、主机目录或模型密钥放进镜像和网页响应。保留普通对话、工具轨迹与记录回看;Coding 练习不在首次公开范围。 +2. 匿名访客凭浏览器持有的标识只看和继续自己的任务;清除浏览器数据后无法找回旧任务。记录在持久目录保留 **7 天**,到期清理。访客隔离覆盖列表、详情、导出与继续对话。 +3. 共享的免费模型调用在服务端设全站与单访客上限;资源忙或额度耗尽时明确提示,不以新密钥、付费模型或自动重试绕过上限。 +4. 模型首选实测 Z.AI 官方列为免费的 `glm-4.7-flash`,验证实际账号资格、Chat Completions 工具调用、错误处理和额度边界后再上线。供应商条款允许 API 集成到面向终端用户的应用,也要求管理终端用户行为。[价格表](https://docs.z.ai/guides/overview/pricing) · [API 条款](https://chat.z.ai/legal-agreement/terms-of-service) +5. 不把 OpenCode Zen 的 `muse-spark-1.3-contributor-free` 接给匿名公众:该免费项有期限,且 OpenCode 当前条款将服务限定为自己的内部使用。[免费项说明](https://opencode.ai/docs/zen/) · [使用条款](https://opencode.ai/legal/terms-of-service) +6. 在 DMIT 上保留已有 xray / 3x-ui。只读预检见到 Debian 13、x86_64、2 vCPU、约 2 GiB 内存、约 35 GiB 剩余磁盘;8443 已占用,Docker 尚未安装。安装 Docker 前核对其网络规则影响,部署后复核原服务。`loop.liuvv.com` 在本次核查时尚无 DNS 记录;公网 80/443 可达性尚未由独立外部网络证明。 -本次操作进度、最近验证通过的版本和应用访问检查分别显示,避免把访问超时当作发布失败。未知结果会引导核对原任务,核对前阻止重复提交。原流程视图已通过三条引导流程的浏览器点击检查、内置 10 项模拟状态检查,以及 1440px / 390px 宽度的显示检查;这些结果不代表正式软件、真实持久化或云端恢复已验收。 +### 验收标准 -补充可用性检查已修正重绘后键盘焦点丢失、拒绝原因只显示在页面下方的问题;三条流程可通过键盘逐步完成,预检失败和被拒绝操作会在产品区域显示原因。 +- 记录源码版本、镜像标识、目标主机、应用配置与操作结果;不输出模型密钥。 +- `https://loop.liuvv.com` 证书正常;两个独立浏览器可分别提交真实任务,完成至少一次工具调用,并且无法列出、读取、导出或续接对方记录。 +- 一次普通发布更新后,新版本生效,已有访客记录仍可回看;到期记录被清理。一次受控失败能通过日志定位并恢复;结果未知时先核对再操作。 +- `/healthz` 只证明 HTTP 服务存活;另用一次真实模型任务验证业务链。免费模型不可用时,网页能说明原因而不会悄悄改用付费接口。 +- 发布前后分别核对原有 xray 服务、监听端口和网络可达性;不能把 Loop 正常误当作原服务未受影响。 -用户认为原页面过于复杂后,已将演示控件移出默认视图。新版应用面板已验证模拟更新完成、日志及账号等弹窗可正常打开关闭,评审入口仍可访问;原有 10 项模拟状态检查继续通过。这些是原型验证结果,正式 CLI、Skill 和云平台管理能力仍未交付。 +## 后续扩展触发条件 -用户查看新版后回复“可以”,已认可简化界面作为首版设计基准。节点切换后的网络复测、一次自然冷启动,以及改用应用自定义配置后的一次故障与恢复验证已通过。原 PORT 差异仍未解决。当前费用条件见[复核记录](docs/research/2026-09-16-free-plan-boundaries.md)。 +- **PostgreSQL**:第二个真实应用确实需要关系数据时,先支持连接用户已有数据库;若选择同机数据库,补持久卷、独立备份和实际恢复验证。Loop 不需要 PostgreSQL,不为展示功能而安装。 +- **对象存储**:先分清应用文件桶和异地备份桶。真实应用需要文件上传或备份时,连接用户自有的 S3 兼容桶;不在小型 VPS 上先建对象存储服务。存储供应商可能要求账单开通,必须单独选择。 +- **其他主机与平台**:只有单机路径在 Loop 及至少一个外部开发者项目上可复现,并且现有工具仍留下明确痛点时,再扩展供应商、原生发布或团队权限。 -### 内部试验版实现进展 - -用户选择先推进内部版后,采用 Go 标准库、本地 JSON 操作记录、嵌入式网页及已固定版本的官方 CLI,实现状态/归属/费用预检、已知凭证脱敏日志、源码发布和中断核对。每个本地项目使用进程锁防止并行提交;先持久记录唯一标记,再上传源码,结果未知时查找已有部署。已完成的操作保留历史结论,当前资源状态与 HTTP 检查分别查询。 - -网页默认保持极简,只轮询本地记录;日志和细节按需查看。HTTP 服务绑定 loopback,使用随机会话凭证并核验 Host/Origin。Agent 通过同一 CLI 操作,Skill 随源码提供,未全局安装。 - -本版先管理已关联资源,不自动创建、删除或通用接管云资源,不修改云配置、账单或迁移数据库;写入仅覆盖已验证的 Trial 账号路径。真实验证包含新进程接续、网页发布、CLI 到网页的接续,以及样例源码 v2→v3 与原数据保留。实际强杀正在提交的进程尚未实测,丢失响应后的处理通过隔离的供应商调用测试验证;外部采用与其他 Agent/本机系统仍需单独验收。 - -## 操作边界草案 - -| 操作 | 建议规则 | -| --- | --- | -| 查看资源状态、部署结果和脱敏日志 | 在项目授权范围内执行 | -| 发布应用更新、重启、对确定失败的操作重试 | 在已授权的资源与免费方案内执行;结果未知时先查询核对 | -| 首次创建资源 | 展示账号、资源及免费条件,纳入用户选择的项目授权范围 | -| 改用付费计划、增加可能计费的资源或改变费用边界 | 单独确认,不归入常规操作授权 | -| 删除数据库、清空数据、覆盖迁移目标等高影响操作 | 单独确认,明确目标与影响范围 | - -上述规则仅描述通过本工具执行的操作。具体授权的作用范围、撤销方式和高影响操作清单仍需在实现前细化。 - -## 首版验收草案 - -1. 在独立测试账号或隔离资源中完成所选免费组合的资格、授权、API 与数据库连通性验证。Trial 的成功不能代替正式 Free 条件验证。 -2. 通过现有编码 Agent 部署公开示例,访问应用并完成一次数据库写入和读取。 -3. 修改示例后重新发布,已有业务数据保持正常。 -4. 注入一次可恢复的配置错误,能够定位、修复、重新部署,并核对没有重复创建资源。 -5. 关闭本地管理工具后,已上线应用仍可直接通过资源平台访问;重新打开工具后能读取并核对已有状态。 -6. 验证日志与展示结果不暴露密钥。免费路径不会自动切换成付费计划,资格或网络条件不足时给出明确原因。 -7. 迁移验收范围已确认:迁移示例应用自己的业务表和数据到用户提供的另一个兼容 PostgreSQL,允许计划停机,保留源库,并验证目标应用功能。接管目标云项目不属于首版要求。 -8. 在本地工具已安装、用户供应商授权有效且供应商服务可达的条件下,阻断作者运营的后端,仍能完成预检、部署、更新、查询状态和日志、恢复及约定范围的迁移。该要求不同于断网运行;用户应用的主要访问入口也不能被强制绑定到作者持有的域名。 -9. 在相同的安装、供应商授权和网络前提下,不使用 Agent 或模型,仍能直接执行已支持的部署、更新、查询与恢复操作;换一段 Agent 对话后仍可读取已有资源关联和操作记录,不依赖原聊天记录重建管理状态。 -10. 关闭网页后仍可重新连接查看进行中的任务;在云端操作已提交、本地流程尚未完成时退出本地执行进程,重启后能核对云端结果并继续处理,不重复创建已有资源。供应商暂时不可达时显示结果待核实,不宣称已经恢复或自动重复提交。 - -`demo.readwrite.fun` 属于作者以普通用户身份使用自己域名与云账号的验证样例,用于验证自有域名绑定,不代表给所有用户提供公共根域名。该域名的证书及 HTTPS 验收进展见 [云端记录](validation/CLOUD-RESULTS.md)。 - -本地容器层已验证读写、真实重建换版、数据库故障恢复、TLS 校验及两个临时 PostgreSQL 之间的业务数据迁移。云端已完成个人账号预检、Go 的 Trial 构建、读写、更新、一次自然休眠唤醒,以及一次应用自定义配置故障的诊断与恢复。正式 Free、额度耗尽、其他故障类型、真实云间数据迁移及正式 Agent 管理接口仍待验证;PORT 测试仍单独保留为未决事项。 - -### 兼容与迁移验收增强建议(待确认) - -- 明确区分支持的应用范围、需要部署适配的应用及不适合当前组合的应用。构建通过不能替代业务行为验证。 -- 为公开示例保留普通运行路径。标准 Linux 容器交付已确认;迁出验证建议使用同一镜像或保留的构建配置恢复功能。若提供 Workers 部署,需单独验证适配及迁出。 -- 建议共用一套验收流程跑 Go、Python、Node、Rust 四个小型 Web/API 样例,覆盖构建、启动、HTTP/数据库访问与更新;语言覆盖不等于所有框架、依赖和工作负载都已验证。 -- 计算环境迁出与数据库迁移分别验证。Docker 本地运行只证明运行可移植性;另一个云平台的网络、域名、权限和费用仍需另验。 -- 数据库迁移前检查版本、扩展、权限和业务表依赖。无增量同步时,最终快照前停止所有业务写入,恢复并核对后再切换。 -- 区分目标库开放业务写入前后的回退:开放前可保留回源方案,开放后不能直接切回旧源而宣称没有数据丢失。 -- 用户可保留应用源码、构建配置、数据备份和资源标识,并通过供应商官方方式继续管理;卸载本工具不应成为继续运行或迁出的障碍。密钥不得明文混入普通导出资料。 - -## 候选技术验证 - -用户已确认标准 Linux 容器交付,以此承接多语言应用;下一步选择实际满足运行和费用条件的容器计算平台,与 PostgreSQL 组成首条验证链路。Cloudflare Workers 可作为适用项目的可选目标;其 Rust 支持走 Wasm,不代表普通 Rust 服务可以原样运行。Cloudflare Containers 支持普通容器,但需要用户主动选择付费计划。 - -已有镜像或 Dockerfile 优先复用;没有构建配置时,可以复用目标平台的自动构建能力,或由用户已有编码 Agent 辅助生成可读配置,再经实际构建和启动验证。Railpack 当前支持 Go、Python、Node、Rust,但不是所有项目零配置成功的保证。首版不自研覆盖所有语言的构建系统。 - -目标平台构建优先已经确认;Railway Trial + Neon Free 已完成一条 Go 部署和更新流程,正式供应商选择与长期费用尚未定案。依据见 [云端实测](validation/CLOUD-RESULTS.md)、[计算平台筛选](docs/research/2026-09-16-free-compute-selection.md)、[Cloudflare 专项研究](docs/research/2026-09-16-cloudflare-first-evaluation.md)、[兼容性与多语言研究](docs/research/2026-09-16-compatibility-portability.md)。 - -Terraform / OpenTofu 可用于适合声明式管理的资源;不预先要求所有平台操作都经过同一种工具。具体选择待首条组合明确后验证。 - -## 技术验证准备(已获授权) - -核心产品边界已收敛。建议下一步准备独立的多语言样例与可复用验收流程,优先以 Railway Free + Neon Free 为容器加数据库的技术验证候选;这个顺序不是正式选定供应商。该候选同时对应标准容器、目标平台构建和 PostgreSQL;如实际账号或免费条件不成立,应记录失败并重新评估候选,不能自动付费绕过。 - -验证分为三步: - -1. **本地准备**:独立编写 Go/Python/Node/Rust 的最小 Web/API 样例及 Dockerfile;整理端口、配置、日志、健康检查、数据库读写与版本变更的统一检查项。 -2. **账号与云端验证**:接入用户明确选择的个人测试账号或隔离资源,核对实际计划、权限和免费条件后,再按获授权范围执行构建与部署;不借用公司账号或已有生产业务资源。 -3. **形成证据**:记录部署、更新、故障恢复、费用模式与数据库迁移的结果,将实际通过和未通过的条件回写支持范围。正式 Free 的验证不能用 Trial 成功替代。 - -建议首版 HTTP Web/API 运行约定:监听配置端口、日志写标准输出、配置和密钥从环境注入、持久数据外置。具体约定及前述验收增强一并供用户审阅。 - -免费是已确认的默认费用约束,兼容性与迁移性也是重要选择依据;不能为了得到免费目标而静默改写语言、数据库引擎或业务语义。若当前项目没有满足约束的组合,应明确说明并让用户选择调整范围或主动选择付费路径。 - -内部版已选择 Go 标准库程序、CLI 接入 Agent、内嵌网页及本地操作记录;公开安装包和更多接入范围继续按实际验收推进。本地样例、云端实际验收与内部版验收分别记录,当前个人云测试资源保留用于后续验证。 - -## 本地密钥存储决策(2026-09-17 更新) - -用户选择优先采用简单的本地文件方案。业务密钥单独放在私有状态目录的 `secrets/项目名.json` 中,普通配置、CLI 输出和默认网页响应不包含密钥值。macOS/Linux 通过目录 0700、文件 0600 限制其他普通用户访问,密钥仍是明文,不能防止当前用户下有读取权限的程序。原钥匙串实现退出首版依赖;Windows ACL 和文件锁需另外适配并验收。存储迁移保留所有操作标记及未决写入状态。 - -## Vercel 发布验收(2026-09-17) - -已有 Vercel Hobby 项目与 Neon Free 已通过 Ship CLI 的两轮源码发布、更新、HTTP/PostgreSQL 读写、数据保留和日志检查。普通部署保留平台现有 Secret,不要求读取或重写密码;数据库配置不可读时明确显示未核验。历史 Secret 写入在用户确认后按“经诊断未生效”归档,原字段和证据保留,没有重发。真实业务 Secret 元数据保持不变。[验收记录](validation/results/ship-vercel-acceptance.json) - -## 2026-09-18 安装入口 - -新用户通过一个安装脚本取得预编译 CLI 与固定版本的供应商工具,不再需要 Go 或 Git。安装器配置 zsh / bash 的 PATH,并以符号链接注册 Codex / Claude Code Skill;同名文件冲突时保留原文件。需要 Node.js 24+ 与 npm,云资源仍由用户自己持有,独立开发者试用仍待完成。[开始使用](docs/FIRST-TRY.md) - -## 2026-09-18 数据库按需关联 - -Neon 不再是应用登记的必填项。省略全部 Neon 标识时只管理应用,不调用 Neon、不要求 `DATABASE_URL`,访问检查只执行 `/healthz`;显式关联 Neon 后沿用归属、Free 套餐、连接配置与 `/readyz` 核验。部分填写标识会被拒绝,已有数据库关联保持原样。计算平台的费用条件与发布授权继续独立检查。 - -## 2026-09-18 更名 Carry - -CLI、Skill、安装包与 GitHub 仓库统一使用 Carry。新状态目录为 `~/.carry`,已有用户沿用 `~/.ship` 等历史目录,不自动复制密钥或重建项目。新操作使用 `carry:` 标记,历史 Ship / UpOK / pdeploy 标记仍按原记录核对;供应商既有项目、域名和数据库不因工具改名而变更。 +当前 Vercel、Railway、Neon Alpha 与其验证结论继续见 [Alpha 说明](docs/ALPHA.md)、[本地结果](validation/RESULTS.md)、[云端结果](validation/CLOUD-RESULTS.md)。它们不是上述 VPS 路径已经完成的证据。 diff --git a/README.md b/README.md index 95beb14..c426eec 100644 --- a/README.md +++ b/README.md @@ -13,3 +13,9 @@ Do not paste tokens. If you only have code and no cloud app yet, the agent shoul **[Get started](https://github.com/unix2dos/carry/blob/main/docs/FIRST-TRY.md)** — install, current limits (macOS Apple Silicon, existing Vercel Hobby app; Neon optional), and the first deploy. [Alpha notes](docs/ALPHA.md) · [Product scope](PRODUCT.md) · [License](LICENSE) + +## VPS path in development + +The source tree now has a single-server VPS path for an existing Linux host reached through a known SSH alias. It builds a Dockerfile locally for the host architecture, transfers the image over SSH, and runs the app with a Caddy HTTPS proxy through Docker Compose. The current published Alpha installer still documents Vercel/Railway; this VPS path needs local Docker, remote Docker, a user-owned domain, and a manually configured DNS record. + +`carry register --provider vps` binds one app to one host; `status`, `publish`, `reconcile`, `logs`, `check`, and image-only `rollback` use the same local operation history as cloud deployments. Secrets enter through `carry secret save NAME KEY --stdin`, never `--vps-env`. The first real acceptance target and its remaining checks are in [the product plan](PRODUCT.md). diff --git a/cmd/carry/engine.go b/cmd/carry/engine.go index bdda302..998aa44 100644 --- a/cmd/carry/engine.go +++ b/cmd/carry/engine.go @@ -39,6 +39,11 @@ func validateReferences(p *Project) error { return errors.New("Vercel requires explicit team and project IDs") } ids = append(ids, p.VercelTeam, p.VercelProject) + case "vps": + if p.hasNeon() || !vpsHostPattern.MatchString(p.VPSHost) || p.VPSPort < 1 || p.VPSPort > 65535 || + (p.VPSDataPath != "" && !validVPSDataPath(p.VPSDataPath)) || !validVPSEnv(p.VPSEnv) { + return errors.New("VPS requires a safe SSH host alias, container port and optional absolute data path/non-secret environment") + } default: return errors.New("unsupported compute provider") } @@ -157,7 +162,8 @@ func bundleSource(source, root string, secrets []string) (dir, digest string, co return fmt.Errorf("source contains a symlink: %s; alpha does not follow symlinks", rel) } if d.IsDir() { - return os.MkdirAll(filepath.Join(dir, rel), 0700) + // The private staging root protects local source; copied image directories must be traversable by non-root app users. + return os.MkdirAll(filepath.Join(dir, rel), 0755) } info, e := d.Info() if e != nil || !info.Mode().IsRegular() { @@ -284,6 +290,50 @@ func (e *Engine) executePublish(ctx context.Context, p Project, op *Operation, w } return nil } +func (e *Engine) rollbackVPS(ctx context.Context, p Project, op *Operation) error { + if p.Provider != "vps" { + return e.record(op, "blocked", "rollback currently supports only VPS applications") + } + current, err := e.Providers.vpsDeployments(ctx, p) + if err != nil || len(current) != 1 { + e.record(op, "blocked", "current VPS application could not be identified") + return errors.New("current VPS application could not be identified") + } + operations, err := e.Store.operations(p.Name) + if err != nil { + return err + } + var prior *Operation + for i := range operations { + if operations[i].State == "deployed" && operations[i].Marker != current[0].Meta.Message { + prior = &operations[i] + break + } + } + if prior == nil { + return e.record(op, "blocked", "no earlier deployed image is available to restore") + } + image := vpsImage(p, prior.Marker) + if _, err = e.Providers.vpsCall(ctx, p, "image", "inspect", image, "--format", "{{.Id}}"); err != nil { + e.record(op, "blocked", "earlier image is not available on the VPS") + return err + } + op.SourceHash, op.SourceFiles = prior.SourceHash, prior.SourceFiles + if err = e.record(op, "submitting", "restoring an earlier application image; current environment and data remain in place"); err != nil { + return err + } + submitErr := e.Providers.applyVPSCompose(ctx, p, image, op.Marker) + if err = e.record(op, "unknown", "rollback result needs readback"); err != nil { + return err + } + if err = e.reconcileOnce(ctx, p, op); err != nil { + return err + } + if submitErr != nil && op.DeploymentID == "" { + return errors.New("rollback outcome is unknown; reconcile before another operation") + } + return nil +} func (e *Engine) reconcileOnce(ctx context.Context, p Project, op *Operation) error { if op.State == "blocked" { return nil diff --git a/cmd/carry/main.go b/cmd/carry/main.go index b09ccb5..966c9af 100644 --- a/cmd/carry/main.go +++ b/cmd/carry/main.go @@ -15,7 +15,7 @@ import ( var version = "dev" -const help = `carry — alpha, existing Railway or Vercel projects with optional Neon +const help = `carry — local deployment manager Global flags (before command): --state-dir PATH Private local records (default: ~/.carry; existing legacy state reused) @@ -27,19 +27,24 @@ Global flags (before command): Commands: version Print the installed CLI version (also: --version) - register --name NAME --source DIR --url HTTPS_ORIGIN [--provider vercel|railway] + register --name NAME --source DIR --url HTTPS_ORIGIN [--provider vercel|railway|vps] --vercel-team ID --vercel-project ID [--neon-org ID --neon-project ID --neon-endpoint ID] (all three or none) [--allow-publish] [--allow-hobby] Default: Vercel; --allow-hobby accepts personal noncommercial Hobby conditions Railway: use --provider railway --workspace ID --railway-project ID --service ID --environment ID instead of Vercel IDs; [--allow-trial] + VPS: --provider vps --vps-host SSH_ALIAS --vps-port CONTAINER_PORT + [--vps-data-path ABSOLUTE_CONTAINER_PATH] [--vps-env KEY=VALUE]... + Save secrets separately with 'secret save NAME KEY --stdin' Existing projects keep their bound provider for all subsequent operations + rebind NAME --source DIR Change the bound source directory after verifying it list - status NAME Live read-only ownership, account plan and resource checks + status NAME Live read-only ownership, account plan or VPS container checks check NAME GET /healthz; also /readyz when Neon is bound; no business writes logs NAME Last 40 deployment log lines; known credentials redacted - publish NAME [--detach] Upload a captured source directory to the bound service + publish NAME [--detach] Publish captured source to the bound service or VPS + rollback NAME VPS only: restore an earlier application image; keep data and current secrets reconcile NAME [--wait] Find the existing operation by its deployment marker history NAME secret save NAME KEY --stdin Save plaintext in an owner-only local file; no cloud changes @@ -50,8 +55,9 @@ Commands: authorize NAME [--allow-publish=true|false] [--allow-trial=true|false] serve [--port 0] [--open] Loopback-only local webpage with session authentication -Official CLI login remains a user-owned prerequisite. This alpha does not create, -delete or adopt whole cloud projects, change billing, or migrate databases. +Official cloud CLI login remains a user-owned prerequisite. VPS publishing needs +local Docker and an SSH-bound Docker Engine on the user's server. Carry does not +create cloud accounts, purchase servers, change billing, or migrate databases. All non-server command outputs are JSON. Business secret values are stored separately in private local files and never printed. Official CLI login credentials stay with the official tools. Use one explicit project authorization for regular updates. @@ -171,7 +177,7 @@ func run(ctx context.Context, args []string) error { if settings.NeonConfig == "" { settings.NeonConfig = filepath.Join(home, ".config", "neon") } - providers := &Providers{Railway: resolveTool(settings.Railway, "railway", "@railway/cli/bin/railway", "CARRY_RAILWAY_BIN", "SHIP_RAILWAY_BIN", "UPOK_RAILWAY_BIN"), Neon: resolveTool(settings.Neon, "neon", ".bin/neon", "CARRY_NEON_BIN", "SHIP_NEON_BIN", "UPOK_NEON_BIN"), NeonConfig: settings.NeonConfig, Vercel: resolveTool(settings.Vercel, "vercel", ".bin/vercel", "CARRY_VERCEL_BIN", "SHIP_VERCEL_BIN", "UPOK_VERCEL_BIN"), VercelConfig: settings.VercelConfig, Store: store} + providers := &Providers{Railway: resolveTool(settings.Railway, "railway", "@railway/cli/bin/railway", "CARRY_RAILWAY_BIN", "SHIP_RAILWAY_BIN", "UPOK_RAILWAY_BIN"), Neon: resolveTool(settings.Neon, "neon", ".bin/neon", "CARRY_NEON_BIN", "SHIP_NEON_BIN", "UPOK_NEON_BIN"), NeonConfig: settings.NeonConfig, Vercel: resolveTool(settings.Vercel, "vercel", ".bin/vercel", "CARRY_VERCEL_BIN", "SHIP_VERCEL_BIN", "UPOK_VERCEL_BIN"), VercelConfig: settings.VercelConfig, Docker: resolveTool("", "docker", ""), SSH: resolveTool("", "ssh", ""), Store: store} engine := &Engine{Store: store, Providers: providers} switch args[0] { case "secret": @@ -190,6 +196,7 @@ func run(ctx context.Context, args []string) error { case "register": f := flag.NewFlagSet("register", flag.ContinueOnError) var p Project + var vpsEnv envFlags f.StringVar(&p.Provider, "provider", "vercel", "compute provider") f.StringVar(&p.VercelTeam, "vercel-team", "", "Vercel team ID") f.StringVar(&p.VercelProject, "vercel-project", "", "Vercel project ID") @@ -206,6 +213,10 @@ func run(ctx context.Context, args []string) error { f.StringVar(&p.NeonEndpoint, "neon-endpoint", "", "Neon endpoint") f.BoolVar(&p.AllowPublish, "allow-publish", false, "authorize regular source updates to this exact service") f.BoolVar(&p.AllowTrial, "allow-trial", false, "explicitly accept the current Trial account for internal testing") + f.StringVar(&p.VPSHost, "vps-host", "", "existing SSH host alias") + f.IntVar(&p.VPSPort, "vps-port", 0, "application container port") + f.StringVar(&p.VPSDataPath, "vps-data-path", "", "optional persistent container directory") + f.Var(&vpsEnv, "vps-env", "repeatable non-secret KEY=VALUE") if err = f.Parse(args[1:]); err != nil { return err } @@ -215,11 +226,26 @@ func run(ctx context.Context, args []string) error { if p.Source == "" { return errors.New("source is required") } + p.VPSEnv = vpsEnv if err = validateProject(&p); err != nil { return err } - if _, err = providers.inspect(ctx, p); err != nil { - return err + if p.Provider == "vps" { + projects, e := store.projects() + if e != nil { + return e + } + // ponytail: one Carry app per VPS until a second real app justifies a shared HTTPS proxy. + for _, current := range projects { + if current.Provider == "vps" && current.VPSHost == p.VPSHost { + return errors.New("this VPS already has a Carry app; shared-domain routing is not supported yet") + } + } + } + if p.Provider != "vps" { + if _, err = providers.inspect(ctx, p); err != nil { + return err + } } if err = store.register(p); err != nil { return err @@ -256,6 +282,38 @@ func run(ctx context.Context, args []string) error { return errors.New("registered project not found") } switch args[0] { + case "rebind": + f := flag.NewFlagSet("rebind", flag.ContinueOnError) + source := f.String("source", "", "new source directory") + if err = f.Parse(args[2:]); err != nil { + return err + } + if f.NArg() != 0 || *source == "" { + return errors.New("use rebind NAME --source DIR") + } + p.Source = *source + if err = validateProject(&p); err != nil { + return err + } + unlock, err := store.lock(p.Name) + if err != nil { + return err + } + defer unlock() + ops, err := store.operations(p.Name) + if err != nil { + return err + } + for _, op := range ops { + if !op.terminal() { + return errors.New("reconcile the unfinished operation before changing source") + } + } + if err = atomicJSON(filepath.Join(store.Root, "projects", p.Name+".json"), p); err != nil { + return err + } + output(p) + return nil case "authorize": f := flag.NewFlagSet("authorize", flag.ContinueOnError) f.BoolVar(&p.AllowPublish, "allow-publish", p.AllowPublish, "authorization for future source updates to this exact service") @@ -332,6 +390,21 @@ func run(ctx context.Context, args []string) error { err = operationError(op) } return err + case "rollback": + if len(args) != 2 { + return errors.New("use rollback NAME") + } + op, unlock, err := engine.begin(p) + if err != nil { + return err + } + defer unlock() + err = engine.rollbackVPS(ctx, p, op) + output(op) + if err == nil { + err = operationError(op) + } + return err case "reconcile": wait := false if len(args) == 3 && args[2] == "--wait" { diff --git a/cmd/carry/main_test.go b/cmd/carry/main_test.go index 2b26da2..f7fcce0 100644 --- a/cmd/carry/main_test.go +++ b/cmd/carry/main_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "reflect" "strings" "testing" ) @@ -224,6 +225,32 @@ func TestOwnershipMismatchAndSecretSafeSource(t *testing.T) { t.Fatal("source symlink followed") } } +func TestBundledSourceKeepsPrivateRootAndReadableImageDirectories(t *testing.T) { + e, p := fixture(t) + nested := filepath.Join(p.Source, "web", "dist") + if err := os.MkdirAll(nested, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(nested, "app.js"), []byte("ready"), 0644); err != nil { + t.Fatal(err) + } + stage, _, _, err := bundleSource(p.Source, e.Store.Root, nil) + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(stage) + rootInfo, err := os.Stat(stage) + if err != nil { + t.Fatal(err) + } + dirInfo, err := os.Stat(filepath.Join(stage, "web", "dist")) + if err != nil { + t.Fatal(err) + } + if rootInfo.Mode().Perm() != 0700 || dirInfo.Mode().Perm()&0005 != 0005 { + t.Fatal("staging root must stay private while image directories allow non-root traversal") + } +} func TestPrivateStateAndProjectLock(t *testing.T) { e, p := fixture(t) if err := validateProject(&p); err != nil { @@ -404,7 +431,7 @@ func TestRenameReusesExistingState(t *testing.T) { t.Fatal(err) } got, err := reopened.project(p.Name) - if err != nil || got != p { + if err != nil || !reflect.DeepEqual(got, p) { t.Fatal("rename lost an existing project or its authorizations") } if _, err = os.Stat(carryDir); !os.IsNotExist(err) { diff --git a/cmd/carry/provider.go b/cmd/carry/provider.go index efa1b77..e6792ba 100644 --- a/cmd/carry/provider.go +++ b/cmd/carry/provider.go @@ -18,6 +18,8 @@ import ( type Providers struct { Railway string + Docker string + SSH string Neon string NeonConfig string Vercel string @@ -102,6 +104,12 @@ func (v *Providers) callInput(ctx context.Context, tool string, input []byte, ar if tool == "vercel" { bin = v.Vercel } + if tool == "docker" { + bin = v.Docker + } + if tool == "ssh" { + bin = v.SSH + } if bin == "" { return nil, fmt.Errorf("%s CLI is not configured", tool) } @@ -109,6 +117,9 @@ func (v *Providers) callInput(ctx context.Context, tool string, input []byte, ar if len(args) > 0 && ((tool == "railway" && args[0] == "up") || (tool == "vercel" && args[0] == "deploy")) { timeout = 120 * time.Second } + if tool == "docker" { + timeout = 10 * time.Minute + } ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() cmd := exec.CommandContext(ctx, bin, args...) @@ -172,6 +183,9 @@ type Deployment struct { } func (v *Providers) deployments(ctx context.Context, p Project) ([]Deployment, error) { + if p.Provider == "vps" { + return v.vpsDeployments(ctx, p) + } if p.Provider == "vercel" { return v.vercelDeployments(ctx, p) } @@ -206,6 +220,9 @@ type preflight struct { } func (v *Providers) inspect(ctx context.Context, p Project) (preflight, error) { + if p.Provider == "vps" { + return v.inspectVPS(ctx, p) + } if p.Provider == "vercel" { return v.inspectVercel(ctx, p) } @@ -346,6 +363,9 @@ func redact(s string, secrets []string) string { return inlineSecret.ReplaceAllString(s, "[REDACTED]") } func (v *Providers) logs(ctx context.Context, p Project) ([]string, error) { + if p.Provider == "vps" { + return v.vpsLogs(ctx, p) + } if p.Provider == "vercel" { return v.vercelLogs(ctx, p) } diff --git a/cmd/carry/store.go b/cmd/carry/store.go index 9ca8fd8..2a8caf5 100644 --- a/cmd/carry/store.go +++ b/cmd/carry/store.go @@ -18,22 +18,26 @@ var slugPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,47}$`) var idPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]{7,99}$`) type Project struct { - Name string `json:"name"` - Provider string `json:"provider,omitempty"` - VercelTeam string `json:"vercel_team,omitempty"` - VercelProject string `json:"vercel_project,omitempty"` - AllowHobby bool `json:"allow_personal_noncommercial_hobby,omitempty"` - Source string `json:"source"` - URL string `json:"url"` - Workspace string `json:"railway_workspace"` - RailwayProject string `json:"railway_project"` - Service string `json:"railway_service"` - Environment string `json:"railway_environment"` - NeonOrg string `json:"neon_org,omitempty"` - NeonProject string `json:"neon_project,omitempty"` - NeonEndpoint string `json:"neon_endpoint,omitempty"` - AllowPublish bool `json:"allow_publish"` - AllowTrial bool `json:"allow_trial"` + Name string `json:"name"` + Provider string `json:"provider,omitempty"` + VercelTeam string `json:"vercel_team,omitempty"` + VercelProject string `json:"vercel_project,omitempty"` + AllowHobby bool `json:"allow_personal_noncommercial_hobby,omitempty"` + Source string `json:"source"` + URL string `json:"url"` + Workspace string `json:"railway_workspace,omitempty"` + RailwayProject string `json:"railway_project,omitempty"` + Service string `json:"railway_service,omitempty"` + Environment string `json:"railway_environment,omitempty"` + NeonOrg string `json:"neon_org,omitempty"` + NeonProject string `json:"neon_project,omitempty"` + NeonEndpoint string `json:"neon_endpoint,omitempty"` + AllowPublish bool `json:"allow_publish"` + AllowTrial bool `json:"allow_trial"` + VPSHost string `json:"vps_host,omitempty"` + VPSPort int `json:"vps_port,omitempty"` + VPSDataPath string `json:"vps_data_path,omitempty"` + VPSEnv map[string]string `json:"vps_env,omitempty"` } func (p Project) hasNeon() bool { @@ -56,7 +60,7 @@ type Observation struct { Provider string `json:"provider,omitempty"` ComputePlan string `json:"compute_plan,omitempty"` DatabaseBinding string `json:"database_binding,omitempty"` - RailwayPlan string `json:"railway_plan"` + RailwayPlan string `json:"railway_plan,omitempty"` NeonPlan string `json:"neon_plan,omitempty"` ServiceState string `json:"service_state"` DeploymentID string `json:"deployment_id"` diff --git a/cmd/carry/vercel.go b/cmd/carry/vercel.go index 75ad6db..e8bcf14 100644 --- a/cmd/carry/vercel.go +++ b/cmd/carry/vercel.go @@ -274,6 +274,9 @@ func (v *Providers) vercelDeployments(ctx context.Context, p Project) ([]Deploym } func (v *Providers) submit(ctx context.Context, p Project, stage, marker string) error { + if p.Provider == "vps" { + return v.submitVPS(ctx, p, stage, marker) + } if p.Provider == "vercel" { _, err := v.vercelCall(ctx, p, nil, "deploy", stage, "--project", p.VercelProject, "--prod", "--yes", "--json", "--no-wait", "--meta", "carry_operation="+marker) return err diff --git a/cmd/carry/vps.go b/cmd/carry/vps.go new file mode 100644 index 0000000..6af9bea --- /dev/null +++ b/cmd/carry/vps.go @@ -0,0 +1,295 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path" + "regexp" + "strconv" + "strings" + "time" +) + +var vpsHostPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$`) + +type envFlags map[string]string + +func (e *envFlags) String() string { return "" } +func (e *envFlags) Set(value string) error { + key, val, ok := strings.Cut(value, "=") + if !ok || !validVPSEnv(map[string]string{key: val}) { + return errors.New("use a non-secret uppercase KEY=VALUE; save credentials with 'secret save'") + } + if *e == nil { + *e = map[string]string{} + } + (*e)[key] = val + return nil +} + +func validVPSEnv(values map[string]string) bool { + for key, value := range values { + if !secretKeyPattern.MatchString(key) || len(value) > 4096 || strings.ContainsAny(value, "\r\n\x00") || + strings.HasSuffix(key, "_KEY") || strings.HasSuffix(key, "_TOKEN") || strings.HasSuffix(key, "_PASSWORD") || strings.HasSuffix(key, "_SECRET") || key == "DATABASE_URL" { + return false + } + } + return true +} + +func validVPSDataPath(value string) bool { + return path.IsAbs(value) && value != "/" && path.Clean(value) == value && !strings.ContainsAny(value, ":\r\n\x00") +} + +func (v *Providers) vpsCall(ctx context.Context, p Project, args ...string) ([]byte, error) { + return v.call(ctx, "docker", append([]string{"--host", "ssh://" + p.VPSHost}, args...)...) +} + +func (v *Providers) vpsHostResources(ctx context.Context, p Project) (diskMiB, availableMiB int, webPortsBusy bool, err error) { + b, err := v.call(ctx, "ssh", "-oBatchMode=yes", "-oStrictHostKeyChecking=yes", "-oConnectTimeout=5", p.VPSHost, + "df -Pm /var/lib/docker && free -m && ss -H -ltn") + if err != nil { + return 0, 0, false, err + } + for index, line := range strings.Split(string(b), "\n") { + fields := strings.Fields(line) + if index == 1 && len(fields) >= 4 { + diskMiB, _ = strconv.Atoi(fields[3]) + } + if len(fields) >= 7 && fields[0] == "Mem:" { + availableMiB, _ = strconv.Atoi(fields[len(fields)-1]) + } + if len(fields) >= 4 && fields[0] == "LISTEN" && (strings.HasSuffix(fields[3], ":80") || strings.HasSuffix(fields[3], ":443")) { + webPortsBusy = true + } + } + if diskMiB < 1 || availableMiB < 1 { + return 0, 0, false, errors.New("VPS disk or memory readback is incomplete") + } + return diskMiB, availableMiB, webPortsBusy, nil +} + +func (v *Providers) inspectVPS(ctx context.Context, p Project) (preflight, error) { + result := preflight{Observation: Observation{At: time.Now().UTC(), Provider: "vps", ComputePlan: "user_owned_vps", DatabaseBinding: "not_managed", ServiceState: "not_deployed"}} + b, err := v.vpsCall(ctx, p, "info", "--format", "{{json .}}") + if err != nil { + result.Observation.Reason = "Docker on the bound SSH host is unavailable" + return result, err + } + var info struct { + Architecture string `json:"Architecture"` + NCPU int `json:"NCPU"` + MemTotal int64 `json:"MemTotal"` + } + if json.Unmarshal(b, &info) != nil || info.Architecture == "" || info.NCPU < 1 || info.MemTotal < 1 { + return result, errors.New("VPS Docker information is incomplete") + } + result.Observation.ComputePlan = fmt.Sprintf("%s %d CPU %d MiB", info.Architecture, info.NCPU, info.MemTotal>>20) + deployments, err := v.vpsDeployments(ctx, p) + if err != nil { + return result, err + } + if len(deployments) > 1 { + return result, errors.New("more than one Carry application container matches this VPS binding") + } + diskMiB, availableMiB, webPortsBusy, err := v.vpsHostResources(ctx, p) + if err != nil { + return result, err + } + if diskMiB < 2048 || availableMiB < 256 { + result.Observation.Reason = "VPS needs at least 2 GiB free disk and 256 MiB available memory before publishing" + return result, nil + } + if len(deployments) == 0 && webPortsBusy { + result.Observation.Reason = "ports 80 or 443 are already in use by an existing service" + return result, nil + } + if len(deployments) == 1 { + result.Observation.ServiceState = strings.ToLower(deployments[0].Status) + result.Observation.DeploymentID = deployments[0].ID + } + result.Observation.Eligible = true + return result, nil +} + +func (v *Providers) vpsDeployments(ctx context.Context, p Project) ([]Deployment, error) { + b, err := v.vpsCall(ctx, p, "ps", "-a", "--filter", "label=com.docker.compose.project=carry-"+p.Name, + "--filter", "label=com.docker.compose.service=app", "--format", "{{.ID}}") + if err != nil { + return nil, err + } + result := []Deployment{} + for _, id := range strings.Fields(string(b)) { + if !regexp.MustCompile(`^[a-f0-9]{12,64}$`).MatchString(id) || len(result) >= 2 { + return nil, errors.New("VPS returned unexpected container identities") + } + body, err := v.vpsCall(ctx, p, "container", "inspect", id, "--format", "{{json .}}") + if err != nil { + return nil, err + } + var detail struct { + ID string `json:"Id"` + Config struct { + Labels map[string]string `json:"Labels"` + } `json:"Config"` + State struct { + Status string `json:"Status"` + } `json:"State"` + } + if json.Unmarshal(body, &detail) != nil || !strings.HasPrefix(detail.ID, id) || detail.Config.Labels["carry.project"] != p.Name { + return nil, errors.New("VPS application container metadata is invalid") + } + d := Deployment{ID: detail.ID, Status: "FAILED"} + if detail.State.Status == "running" { + d.Status = "READY" + } + d.Meta.Message = detail.Config.Labels["carry.marker"] + result = append(result, d) + } + return result, nil +} + +func (v *Providers) vpsLogs(ctx context.Context, p Project) ([]string, error) { + deployments, err := v.vpsDeployments(ctx, p) + if err != nil || len(deployments) != 1 { + return nil, errors.New("one known VPS application container is required for logs") + } + if v.Docker == "" { + return nil, errors.New("Docker CLI is not configured") + } + commandCtx, cancel := context.WithTimeout(ctx, 40*time.Second) + defer cancel() + cmd := exec.CommandContext(commandCtx, v.Docker, "--host", "ssh://"+p.VPSHost, "logs", "--tail", "40", deployments[0].ID) + cmd.Env = cleanEnv() + var stdout, stderr limitedBuffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + if cmd.Run() != nil || stdout.exceeded || stderr.exceeded { + return nil, errors.New("VPS logs could not be read") + } + return strings.Split(strings.TrimSpace(stdout.String()+stderr.String()), "\n"), nil +} + +func vpsImage(p Project, marker string) string { + return "carry-" + p.Name + ":" + strings.ReplaceAll(marker, ":", "-") +} + +func (v *Providers) vpsCompose(p Project, image, marker string) ([]byte, error) { + environment := map[string]string{} + for key, value := range p.VPSEnv { + environment[key] = strings.ReplaceAll(value, "$", "$$") + } + records, err := v.Store.secretRecords(p.Name) + if err != nil { + return nil, err + } + for key, versions := range records { + value := versions[len(versions)-1].Value + if value == "" { + return nil, errors.New("VPS secret is empty") + } + environment[key] = strings.ReplaceAll(value, "$", "$$") + } + name := "carry-" + p.Name + app := map[string]any{ + "image": image, "restart": "unless-stopped", "container_name": name + "-app", + "labels": map[string]string{"carry.project": p.Name, "carry.marker": marker}, "environment": environment, + } + volumes := map[string]any{name + "-caddy": map[string]any{}} + if p.VPSDataPath != "" { + app["volumes"] = []string{name + "-data:" + p.VPSDataPath} + volumes[name+"-data"] = map[string]any{} + } + proxy := map[string]any{ + "image": "caddy:2.10.2-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d", "restart": "unless-stopped", "container_name": name + "-proxy", + "command": []string{"caddy", "reverse-proxy", "--from", strings.TrimPrefix(p.URL, "https://"), "--to", fmt.Sprintf("app:%d", p.VPSPort)}, + "ports": []string{"80:80", "443:443"}, "volumes": []string{name + "-caddy:/data"}, + } + return json.Marshal(map[string]any{"services": map[string]any{"app": app, "proxy": proxy}, "volumes": volumes}) +} + +func (v *Providers) transferVPSImage(ctx context.Context, p Project, image string) error { + if v.Docker == "" { + return errors.New("Docker CLI is not configured") + } + transferCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) + defer cancel() + reader, writer, err := os.Pipe() + if err != nil { + return err + } + defer reader.Close() + defer writer.Close() + save := exec.CommandContext(transferCtx, v.Docker, "image", "save", image) + load := exec.CommandContext(transferCtx, v.Docker, "--host", "ssh://"+p.VPSHost, "image", "load") + save.Env, load.Env = cleanEnv(), cleanEnv() + save.Stdout, load.Stdin = writer, reader + var saveErr, loadErr, loadOut limitedBuffer + save.Stderr, load.Stderr, load.Stdout = &saveErr, &loadErr, &loadOut + if err = load.Start(); err != nil { + return errors.New("VPS image load could not start") + } + if err = save.Start(); err != nil { + writer.Close() + load.Wait() + return errors.New("local image transfer could not start") + } + reader.Close() + writer.Close() + saveResult := save.Wait() + loadResult := load.Wait() + if saveResult != nil || loadResult != nil || saveErr.exceeded || loadErr.exceeded || loadOut.exceeded { + return errors.New("VPS image transfer result is unknown; inspect the remote image before retrying") + } + return nil +} + +func (v *Providers) submitVPS(ctx context.Context, p Project, stage, marker string) error { + b, err := v.vpsCall(ctx, p, "info", "--format", "{{.Architecture}}") + if err != nil { + return err + } + architecture := strings.TrimSpace(string(b)) + platform := "" + switch architecture { + case "x86_64", "amd64": + platform = "linux/amd64" + case "aarch64", "arm64": + platform = "linux/arm64" + default: + return errors.New("unsupported VPS CPU architecture") + } + image := vpsImage(p, marker) + if _, err = v.call(ctx, "docker", "buildx", "build", "--platform", platform, "--load", "--tag", image, stage); err != nil { + return err + } + if err = v.transferVPSImage(ctx, p, image); err != nil { + return err + } + return v.applyVPSCompose(ctx, p, image, marker) +} + +func (v *Providers) applyVPSCompose(ctx context.Context, p Project, image, marker string) error { + compose, err := v.vpsCompose(p, image, marker) + if err != nil { + return err + } + f, err := os.CreateTemp("", "carry-vps-compose-*.json") + if err != nil { + return err + } + defer os.Remove(f.Name()) + if _, err = f.Write(compose); err == nil { + err = f.Close() + } else { + f.Close() + } + if err != nil { + return errors.New("private VPS Compose file could not be prepared") + } + _, err = v.vpsCall(ctx, p, "compose", "-f", f.Name(), "-p", "carry-"+p.Name, "up", "-d", "--no-build", "--pull", "missing") + return err +} diff --git a/cmd/carry/vps_test.go b/cmd/carry/vps_test.go new file mode 100644 index 0000000..e6acb79 --- /dev/null +++ b/cmd/carry/vps_test.go @@ -0,0 +1,219 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestVPSBindingAndComposeKeepSecretsOutOfProject(t *testing.T) { + root := t.TempDir() + source := filepath.Join(root, "source") + if err := os.Mkdir(source, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "Dockerfile"), []byte("FROM scratch\n"), 0600); err != nil { + t.Fatal(err) + } + p := Project{Name: "loop", Provider: "vps", Source: source, URL: "https://loop.example.test", VPSHost: "known-host", VPSPort: 8080, + VPSDataPath: "/data/loop-runs", VPSEnv: map[string]string{"OPENAI_MODEL": "glm-4.7-flash"}, AllowPublish: true} + if err := validateProject(&p); err != nil { + t.Fatal(err) + } + store, err := newStore(filepath.Join(root, "state")) + if err != nil { + t.Fatal(err) + } + if err = store.register(p); err != nil { + t.Fatal(err) + } + if _, err = store.saveSecret(p.Name, "OPENAI_API_KEY", []byte("private$test")); err != nil { + t.Fatal(err) + } + v := &Providers{Store: store} + image := vpsImage(p, "carry:123-safe") + raw, err := v.vpsCompose(p, image, "carry:123-safe") + if err != nil { + t.Fatal(err) + } + var compose struct { + Services map[string]struct { + Image string `json:"image"` + Environment map[string]string `json:"environment"` + Command []string `json:"command"` + Ports []string `json:"ports"` + Volumes []string `json:"volumes"` + } `json:"services"` + } + if err = json.Unmarshal(raw, &compose); err != nil { + t.Fatal(err) + } + app, proxy := compose.Services["app"], compose.Services["proxy"] + if app.Image != image || app.Environment["OPENAI_API_KEY"] != "private$$test" || app.Environment["OPENAI_MODEL"] != "glm-4.7-flash" || + len(app.Volumes) != 1 || !strings.Contains(app.Volumes[0], ":/data/loop-runs") || proxy.Command[3] != "loop.example.test" || len(proxy.Ports) != 2 { + t.Fatal("VPS Compose did not isolate the app, proxy, data or escaped secret") + } + if docker, err := exec.LookPath("docker"); err == nil { + file := filepath.Join(root, "compose.json") + if err = os.WriteFile(file, raw, 0600); err != nil { + t.Fatal(err) + } + output, err := exec.Command(docker, "compose", "-f", file, "config", "--format", "json").Output() + if err != nil { + t.Fatal("Docker Compose rejected the generated config", err) + } + var resolved struct { + Services map[string]struct { + Environment map[string]string `json:"environment"` + } `json:"services"` + } + if json.Unmarshal(output, &resolved) != nil || resolved.Services["app"].Environment["OPENAI_API_KEY"] != "private$$test" { + t.Fatal("Docker Compose interpolated an escaped secret") + } + } + var unsafe envFlags + if unsafe.Set("OPENAI_API_KEY=visible") == nil { + t.Fatal("secret accepted in public project metadata") + } + p.VPSHost = "-oProxyCommand=bad" + if validateReferences(&p) == nil { + t.Fatal("unsafe SSH host accepted") + } +} + +func TestVPSReadbackMatchesOnlyBoundApplication(t *testing.T) { + p := Project{Name: "loop", Provider: "vps", VPSHost: "known-host"} + const id = "aabbccddeeff" + v := &Providers{Run: func(_ context.Context, tool string, args []string) ([]byte, error) { + if tool != "docker" || len(args) < 4 || args[0] != "--host" || args[1] != "ssh://known-host" { + t.Fatal("Docker did not use the bound SSH host") + } + switch args[2] { + case "ps": + return []byte(id + "\n"), nil + case "container": + return []byte(`{"Id":"aabbccddeeff0000","Config":{"Labels":{"carry.project":"loop","carry.marker":"carry:accepted"}},"State":{"Status":"running"}}`), nil + default: + t.Fatalf("unexpected Docker command: %s", args[2]) + return nil, nil + } + }} + deployments, err := v.vpsDeployments(context.Background(), p) + if err != nil || len(deployments) != 1 || deployments[0].Status != "READY" || deployments[0].Meta.Message != "carry:accepted" { + t.Fatalf("VPS deployment readback failed: %v %#v", err, deployments) + } +} + +func TestVPSPreflightBlocksAnExistingWebListener(t *testing.T) { + p := Project{Name: "loop", Provider: "vps", VPSHost: "known-host"} + busy := true + v := &Providers{Run: func(_ context.Context, tool string, args []string) ([]byte, error) { + if tool == "ssh" { + out := "Filesystem 1M-blocks Used Available Use% Mounted on\n/dev/root 40000 5000 35000 13% /\n total used free shared buff/cache available\nMem: 1973 400 1000 0 573 1500\n" + if busy { + out += "LISTEN 0 4096 0.0.0.0:80 0.0.0.0:*\n" + } + return []byte(out), nil + } + if tool != "docker" { + t.Fatalf("unexpected tool %s", tool) + } + if args[2] == "info" { + return []byte(`{"Architecture":"x86_64","NCPU":2,"MemTotal":2069409792}`), nil + } + if args[2] == "ps" { + return []byte(""), nil + } + t.Fatalf("unexpected Docker command %s", args[2]) + return nil, nil + }} + blocked, err := v.inspectVPS(context.Background(), p) + if err != nil || blocked.Observation.Eligible || !strings.Contains(blocked.Observation.Reason, "80") { + t.Fatalf("occupied web port was not blocked: %v %#v", err, blocked) + } + busy = false + ready, err := v.inspectVPS(context.Background(), p) + if err != nil || !ready.Observation.Eligible { + t.Fatalf("free host was not eligible: %v %#v", err, ready) + } +} + +func TestVPSRollbackRestoresEarlierImageWithoutRepeatingBuild(t *testing.T) { + store, err := newStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + p := Project{Name: "loop", Provider: "vps", VPSHost: "known-host", VPSPort: 8080, URL: "https://127.0.0.1:1", AllowPublish: true} + old, err := newOperation(p.Name) + if err != nil { + t.Fatal(err) + } + old.State, old.SourceHash = "deployed", "old-source" + if err = store.saveOp(&old); err != nil { + t.Fatal(err) + } + current, err := newOperation(p.Name) + if err != nil { + t.Fatal(err) + } + current.State, current.SourceHash = "deployed", "current-source" + if err = store.saveOp(¤t); err != nil { + t.Fatal(err) + } + marker := current.Marker + v := &Providers{Store: store, Run: func(_ context.Context, tool string, args []string) ([]byte, error) { + if tool != "docker" || len(args) < 3 { + t.Fatal("unexpected rollback tool call") + } + switch args[2] { + case "ps": + return []byte("aabbccddeeff\n"), nil + case "container": + return []byte(`{"Id":"aabbccddeeff0000","Config":{"Labels":{"carry.project":"loop","carry.marker":"` + marker + `"}},"State":{"Status":"running"}}`), nil + case "image": + if len(args) < 5 || args[4] != vpsImage(p, old.Marker) { + t.Fatal("rollback did not inspect the earlier image") + } + return []byte("image-id"), nil + case "compose": + if len(args) < 5 { + t.Fatal("rollback Compose file was missing") + } + var config struct { + Services map[string]struct { + Image string `json:"image"` + Labels map[string]string `json:"labels"` + } `json:"services"` + } + if json.Unmarshal(mustRead(t, args[4]), &config) != nil || config.Services["app"].Image != vpsImage(p, old.Marker) { + t.Fatal("rollback did not select the earlier application image") + } + marker = config.Services["app"].Labels["carry.marker"] + return []byte(""), nil + } + t.Fatalf("unexpected rollback Docker command: %s", args[2]) + return nil, nil + }} + e := &Engine{Store: store, Providers: v} + op, unlock, err := e.begin(p) + if err != nil { + t.Fatal(err) + } + defer unlock() + if err = e.rollbackVPS(context.Background(), p, op); err != nil || op.State != "deployed" || op.SourceHash != old.SourceHash || marker != op.Marker { + t.Fatalf("rollback did not reconcile the restored image: %v %#v", err, op) + } +} + +func mustRead(t *testing.T, path string) []byte { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return b +} diff --git a/docs/adr/0003-no-automatic-charges-by-default.md b/docs/adr/0003-no-automatic-charges-by-default.md index 4138b56..1773b02 100644 --- a/docs/adr/0003-no-automatic-charges-by-default.md +++ b/docs/adr/0003-no-automatic-charges-by-default.md @@ -1,5 +1,7 @@ # 默认免费方案不自动产生费用 +2026-09-25 更新:默认计算目标与已购 VPS 成本边界见 [ADR-0006](0006-first-deployment-on-user-owned-vps.md);以下为此前免费云组合的决策背景。 + 默认部署方案不能在免费额度耗尽后自动收费,可以发生额度限制或停用。只有用户主动选择付费方案后,工具才启用对应付费能力。用户允许绑定支付方式,不构成启用自动计费的授权。 这一边界需要供应商侧的计划或有效限制支撑,不能用本地用量监控或预算提醒替代,因为本地工具可能离线。曾考虑的 Cloud Run 付费计费账号存在超额计费,不能直接作为满足此承诺的默认方案;默认计算平台需重新选择。 diff --git a/docs/adr/0005-language-neutral-container-delivery.md b/docs/adr/0005-language-neutral-container-delivery.md index 767a6dc..36e915c 100644 --- a/docs/adr/0005-language-neutral-container-delivery.md +++ b/docs/adr/0005-language-neutral-container-delivery.md @@ -1,5 +1,7 @@ # 以标准容器作为多语言交付入口 +2026-09-25 更新:首条 VPS 路径的构建位置与部署目标见 [ADR-0006](0006-first-deployment-on-user-owned-vps.md);以下保留原云平台构建假设的背景。 + 本项目不按编程语言设置白名单,以目标平台支持的标准 Linux 容器镜像或 Dockerfile 作为通用接入入口。用户已有构建配置优先复用;缺失时由成熟构建工具或用户已有编码 Agent 辅助生成,并通过实际构建、启动和功能检查验证。这样可以复用一套部署流程承接 Go、Python、Node、Rust 及其他满足运行约定的应用。 源码自动识别是便利能力,不构成所有语言、框架和依赖都能零配置构建的承诺。首版继续聚焦 HTTP Web/API,具体运行约定和计算供应商仍待明确;Workers 保留为适用项目的可选目标。 diff --git a/docs/adr/0006-first-deployment-on-user-owned-vps.md b/docs/adr/0006-first-deployment-on-user-owned-vps.md new file mode 100644 index 0000000..24dfb9b --- /dev/null +++ b/docs/adr/0006-first-deployment-on-user-owned-vps.md @@ -0,0 +1,5 @@ +# 首条真实部署路径使用用户已有 VPS + +2026-09-25 已接受。Carry 首先为作者自己解决源码上线、更新与恢复的问题,以已有 DMIT VPS 和 Loop 作为首个真实验收对象。默认复用 OCI 镜像或 Dockerfile,并以单机 Docker Compose 运行;本地管理服务和编码 Agent 仍在用户电脑上。免费云平台额度依赖供应商资格和变化中的限制,不能作为这个产品的长期运行承诺;已有 Vercel / Railway Alpha 继续受支持,但不再决定下一版首用路径。 + +这一决定修订了 [ADR-0003](0003-no-automatic-charges-by-default.md) 对「默认免费计算平台」的假设,以及 [ADR-0005](0005-language-neutral-container-delivery.md) 对「默认由目标平台构建」的假设;仍坚持不在未经用户明确选择时增加自动计费。VPS 上的原有服务须在发布前后核对,应用发布授权不等于整机管理授权。 From a28dd361b3bb1e801b3e443e72cdeec4088c6058 Mon Sep 17 00:00:00 2001 From: unix2dos <22365670+unix2dos@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:40:06 +0800 Subject: [PATCH 2/8] fix: show VPS projects in local dashboard --- cmd/carry/web/app.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/carry/web/app.js b/cmd/carry/web/app.js index 3877c00..523409b 100644 --- a/cmd/carry/web/app.js +++ b/cmd/carry/web/app.js @@ -16,20 +16,20 @@ function time(value){return value?new Date(value).toLocaleString():'尚未检查 function showDialog(title,text,opener=document.activeElement){dialogOpener=opener;$('dialog-title').textContent=title;const pre=document.createElement('pre');pre.textContent=text;$('dialog-body').replaceChildren(pre);$('dialog').showModal();} $('close').addEventListener('click',()=>$('dialog').close()); $('dialog').addEventListener('close',()=>{if(dialogOpener?.isConnected)dialogOpener.focus();load().catch(e=>message(e.message,true));}); -$('add').addEventListener('click',()=>showDialog('关联已有应用','先关联已有 Vercel 或 Railway 应用。只有应用需要时才关联 Neon 数据库。\n\n在已有 Agent 中选择项目,让它使用安装好的 carry Skill,核对资源归属后运行 register。\n\n首次创建云资源、通用资源接管和数据库迁移尚未接入这一版。')); +$('add').addEventListener('click',()=>showDialog('关联应用','可关联已有 Vercel / Railway 应用,或把 Web 项目绑定到一台已有 Docker 的 Linux VPS。只有应用需要时才关联 Neon 数据库。\n\n在已有 Agent 中选择项目,让它使用 Carry 核对目标后运行 register。数据库迁移尚未接入这一版。')); async function load(){ const views=await api('projects');const fragment=document.createDocumentFragment(); for(const view of views){ const p=view.project,o=view.observation,op=view.operations[0];const pending=op&&!terminal.has(op.state); const card=$('card').content.cloneNode(true),article=card.querySelector('article');article.dataset.name=p.name; card.querySelector('.name').textContent=p.name; - const provider=p.provider==='vercel'?'Vercel':'Railway',hasNeon=!!(p.neon_org||p.neon_project||p.neon_endpoint); + const provider=p.provider==='vercel'?'Vercel':p.provider==='vps'?'VPS':'Railway',hasNeon=!!(p.neon_org||p.neon_project||p.neon_endpoint); card.querySelectorAll('[data-neon]').forEach(element=>element.hidden=!hasNeon); card.querySelector('.plan').textContent=o?provider+' '+(o.compute_plan||o.railway_plan||'待确认')+(hasNeon?' · Neon '+(o.neon_plan||'待确认'):''):'尚未读取资源状态'; const status=card.querySelector('.status'); const newerOperation=op?.provider_state&&(!o||new Date(op.updated_at)>new Date(o.at)); const cloudState=newerOperation?op.provider_state:o?.service_state; - status.textContent=pending?(op.state==='unknown'?(view.running?'正在确认发布':'结果待核实'):'正在发布'):op?.state==='blocked'?'发布未执行':op?.state==='failed'?'最近发布失败':cloudState==='SLEEPING'?'最近状态:休眠':['SUCCESS','READY'].includes(cloudState)?'最近部署成功':'状态待刷新'; + status.textContent=pending?(op.state==='unknown'?(view.running?'正在确认发布':'结果待核实'):'正在发布'):op?.state==='blocked'?'发布未执行':op?.state==='failed'?'最近发布失败':cloudState==='SLEEPING'?'最近状态:休眠':['SUCCESS','READY','ready','running'].includes(cloudState)?'最近部署成功':'状态待刷新'; if(pending||!o||op?.state==='blocked')status.classList.add('warn');if(op?.state==='failed')status.classList.add('bad'); card.querySelector('.address').textContent=p.url;card.querySelector('.visit').href=p.url; card.querySelector('[data-action="publish"]').disabled=!!pending||!p.allow_publish; From b33e7a33063de3ed0502b3438e29117e6e68a949 Mon Sep 17 00:00:00 2001 From: unix2dos <22365670+unix2dos@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:41:56 +0800 Subject: [PATCH 3/8] docs: teach Carry skill the VPS deployment path --- skills/carry/SKILL.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/skills/carry/SKILL.md b/skills/carry/SKILL.md index 6c50c99..a7dabe1 100644 --- a/skills/carry/SKILL.md +++ b/skills/carry/SKILL.md @@ -1,24 +1,23 @@ --- name: carry -description: "Manage projects registered with the local carry tool: inspect Railway/Vercel and optional Neon status, publish source, inspect logs, and reconcile interrupted operations. Use for this tool's registered projects; resource creation and database migration are not implemented in its internal alpha." +description: "Deploy and manage projects registered with the local Carry tool on a user-owned VPS or existing Vercel/Railway resources. Use for Carry bindings, release checks, logs, interrupted operations, and VPS image rollback." --- # Carry -Use `carry` from PATH. If this Agent process has not picked up the new PATH, use `~/.local/bin/carry`. Read the installed guide at `~/.local/share/carry/docs/ALPHA.md` for authentication, registration, or supported scope. For a custom installation, resolve the `~/.local/bin/carry` symlink and find `docs/ALPHA.md` beside its `bin` directory. Source checkouts also contain [the alpha guide](../../docs/ALPHA.md). The webpage and CLI share the same local records; opening the webpage is optional. +Use `carry` from PATH, or `~/.local/bin/carry` if PATH has not refreshed. Check `carry help`: the released Alpha may support only existing Vercel/Railway projects, while a source checkout can contain the newer VPS path. Read [the Alpha guide](../../docs/ALPHA.md) for installed cloud support and [the product plan](../../PRODUCT.md) for the VPS scope. The webpage and CLI share local records; the webpage is optional. -Installation registers this Skill for Codex and Claude Code. To set up a new computer, follow [the installation guide](https://github.com/unix2dos/carry/blob/main/docs/FIRST-TRY.md). Cloud login and resource binding are separate steps; explain the current requirement for existing cloud resources before promising a first deployment. +Installation registers this Skill for Codex and Claude Code. To set up a new computer, follow [the installation guide](https://github.com/unix2dos/carry/blob/main/docs/FIRST-TRY.md). Confirm which provider and executable version the user's project is bound to before choosing commands. -1. Run `carry list` and select the project matching the user's request. For a new binding, default to Vercel unless the user selects Railway (`--provider railway`); establish the source directory and exact resource ownership before `register`. Neon is optional: omit all three Neon IDs and skip Neon login for an application-only binding. Add all three IDs only when the application needs Neon and the user wants it managed; partial bindings are rejected. Historical `ship:` operation markers and local records remain valid after the rename. Existing projects keep their saved provider, including legacy Railway records without a provider field. Keep cloud login credentials in the official CLI stores; resource IDs belong in the binding. Carry-owned business secrets are stored separately in private local files, never in source or chat. -2. Run `carry status NAME`. Report the observed account plan and use restrictions; Vercel Hobby requires personal noncommercial use. Trial is a time-limited test path, not proof of a formally validated Free combination. The executable performs fresh ownership and billing checks before each publish. -3. For a requested source update, use `carry publish NAME --detach`, then `carry reconcile NAME --wait`. An existing `allow_publish` authorization covers regular updates to that binding; do not ask for it again. Grant or revoke project permissions with `authorize` only within the user's stated scope. Accept Trial conditions only if the user has already accepted them or confirms that choice now. -4. Read the resulting operation and application checks separately. An application-only binding checks `/healthz`; a Neon binding also checks `/readyz`. A `not_managed` database result means Carry has no database binding, not that the application cannot use another database. `deployed` means the provider finished deployment; claim application accessibility only when the checks passed. Sleep, a failed request, and a failed deployment are different observations. +1. Run `carry list` and select the exact binding. For a new binding, verify the source directory and user-owned target before `register`; preserve an existing project's provider. VPS registration needs a known SSH alias, local and remote Docker, the container port and a user-owned HTTPS origin. A moved source uses `rebind NAME --source DIR` after checking the new tree. Vercel/Railway bindings keep their existing provider IDs; Neon is optional and requires all three IDs or none. Keep business secrets in Carry's private files, never source, chat, or `--vps-env`. +2. Run `carry status NAME`. For VPS, read the host resources, web-port preflight and app container state; also inspect unrelated existing services that the deployment might affect. For Vercel/Railway, report account plan and restrictions; Vercel Hobby is personal noncommercial and Trial is temporary. +3. An existing `allow_publish` authorization covers regular updates to its exact binding. For a requested update, run `carry publish NAME --detach`, then `carry reconcile NAME --wait` if still pending. Read the operation, container/provider state, and `carry check NAME` separately. `/healthz` proves HTTP access, not the application's business flow. A VPS image rollback uses `carry rollback NAME`; it keeps current secrets and data. ## Interrupted or failed work -`unknown`, a timeout, or a lost command response requires `reconcile`, not another `publish`. The stored marker identifies the original cloud deployment. If reconciliation cannot uniquely identify it, preserve that uncertainty and discuss it with the user. Never remove operation records merely to bypass the duplicate-submit guard. +`unknown`, a timeout, or a lost command response requires `reconcile` before another publish. The stored marker identifies the original provider deployment or VPS container. If it cannot be uniquely identified, preserve that uncertainty. Never remove operation records merely to bypass the duplicate-submit guard. -Use `logs` for diagnosis; it suppresses known credentials and common secret patterns, but review output before sharing it. Inspect source changes for migrations or other high-impact behavior before treating an update as routine. Changes to billing, destructive resource actions, and unsupported lifecycle operations require a separate user decision; this skill does not authorize them. +Use `logs` for diagnosis; it suppresses known credentials and common secret patterns, but review output before sharing it. Inspect source changes for migrations or other high-impact behavior before treating an update as routine. For VPS, check the original host services after Docker/network changes and distinguish a running container from working HTTPS and a real application task. Changes to billing, destructive resource actions, and unsupported lifecycle operations require a separate user decision; this skill does not authorize them. ## Local secret files From 55386f81dbb872174c8866126365e4141ec559cd Mon Sep 17 00:00:00 2001 From: unix2dos <22365670+unix2dos@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:45:27 +0800 Subject: [PATCH 4/8] fix: stream VPS Compose config without a secret temp file --- cmd/carry/vps.go | 15 +-------------- cmd/carry/vps_test.go | 17 ++++------------- 2 files changed, 5 insertions(+), 27 deletions(-) diff --git a/cmd/carry/vps.go b/cmd/carry/vps.go index 6af9bea..7816ab4 100644 --- a/cmd/carry/vps.go +++ b/cmd/carry/vps.go @@ -277,19 +277,6 @@ func (v *Providers) applyVPSCompose(ctx context.Context, p Project, image, marke if err != nil { return err } - f, err := os.CreateTemp("", "carry-vps-compose-*.json") - if err != nil { - return err - } - defer os.Remove(f.Name()) - if _, err = f.Write(compose); err == nil { - err = f.Close() - } else { - f.Close() - } - if err != nil { - return errors.New("private VPS Compose file could not be prepared") - } - _, err = v.vpsCall(ctx, p, "compose", "-f", f.Name(), "-p", "carry-"+p.Name, "up", "-d", "--no-build", "--pull", "missing") + _, err = v.callInput(ctx, "docker", compose, "--host", "ssh://"+p.VPSHost, "compose", "-f", "-", "-p", "carry-"+p.Name, "up", "-d", "--no-build", "--pull", "missing") return err } diff --git a/cmd/carry/vps_test.go b/cmd/carry/vps_test.go index e6acb79..f11a254 100644 --- a/cmd/carry/vps_test.go +++ b/cmd/carry/vps_test.go @@ -165,7 +165,7 @@ func TestVPSRollbackRestoresEarlierImageWithoutRepeatingBuild(t *testing.T) { t.Fatal(err) } marker := current.Marker - v := &Providers{Store: store, Run: func(_ context.Context, tool string, args []string) ([]byte, error) { + v := &Providers{Store: store, RunInput: func(_ context.Context, tool string, args []string, input []byte) ([]byte, error) { if tool != "docker" || len(args) < 3 { t.Fatal("unexpected rollback tool call") } @@ -180,8 +180,8 @@ func TestVPSRollbackRestoresEarlierImageWithoutRepeatingBuild(t *testing.T) { } return []byte("image-id"), nil case "compose": - if len(args) < 5 { - t.Fatal("rollback Compose file was missing") + if len(args) < 5 || args[4] != "-" || len(input) == 0 { + t.Fatal("rollback Compose config was not streamed on stdin") } var config struct { Services map[string]struct { @@ -189,7 +189,7 @@ func TestVPSRollbackRestoresEarlierImageWithoutRepeatingBuild(t *testing.T) { Labels map[string]string `json:"labels"` } `json:"services"` } - if json.Unmarshal(mustRead(t, args[4]), &config) != nil || config.Services["app"].Image != vpsImage(p, old.Marker) { + if json.Unmarshal(input, &config) != nil || config.Services["app"].Image != vpsImage(p, old.Marker) { t.Fatal("rollback did not select the earlier application image") } marker = config.Services["app"].Labels["carry.marker"] @@ -208,12 +208,3 @@ func TestVPSRollbackRestoresEarlierImageWithoutRepeatingBuild(t *testing.T) { t.Fatalf("rollback did not reconcile the restored image: %v %#v", err, op) } } - -func mustRead(t *testing.T, path string) []byte { - t.Helper() - b, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - return b -} From b32ebd09c827bf9de151c4463772b9c991c66504 Mon Sep 17 00:00:00 2001 From: unix2dos <22365670+unix2dos@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:07:03 +0800 Subject: [PATCH 5/8] fix: choose the verified free model for Loop VPS --- PRODUCT.md | 2 +- cmd/carry/main.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/PRODUCT.md b/PRODUCT.md index 281ff6e..f93fe1c 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -38,7 +38,7 @@ Carry 是本地运行的部署与结果核对工具。用户已有的编码 Agen 1. 只挂载可公开的示例 Markdown;不把作者私人笔记、主机目录或模型密钥放进镜像和网页响应。保留普通对话、工具轨迹与记录回看;Coding 练习不在首次公开范围。 2. 匿名访客凭浏览器持有的标识只看和继续自己的任务;清除浏览器数据后无法找回旧任务。记录在持久目录保留 **7 天**,到期清理。访客隔离覆盖列表、详情、导出与继续对话。 3. 共享的免费模型调用在服务端设全站与单访客上限;资源忙或额度耗尽时明确提示,不以新密钥、付费模型或自动重试绕过上限。 -4. 模型首选实测 Z.AI 官方列为免费的 `glm-4.7-flash`,验证实际账号资格、Chat Completions 工具调用、错误处理和额度边界后再上线。供应商条款允许 API 集成到面向终端用户的应用,也要求管理终端用户行为。[价格表](https://docs.z.ai/guides/overview/pricing) · [API 条款](https://chat.z.ai/legal-agreement/terms-of-service) +4. 模型首发使用 Z.AI 官方列为免费的 `glm-4.5-flash`:2026-09-25 已用专用 Key 验证 Chat Completions 返回和一次函数工具调用;`glm-4.7-flash` 在同次验证中出现超时和供应商过载。仍须通过 Loop 完成工具回执链与额度边界验收。供应商条款允许 API 集成到面向终端用户的应用,也要求管理终端用户行为。[价格表](https://docs.z.ai/guides/overview/pricing) · [API 条款](https://chat.z.ai/legal-agreement/terms-of-service) 5. 不把 OpenCode Zen 的 `muse-spark-1.3-contributor-free` 接给匿名公众:该免费项有期限,且 OpenCode 当前条款将服务限定为自己的内部使用。[免费项说明](https://opencode.ai/docs/zen/) · [使用条款](https://opencode.ai/legal/terms-of-service) 6. 在 DMIT 上保留已有 xray / 3x-ui。只读预检见到 Debian 13、x86_64、2 vCPU、约 2 GiB 内存、约 35 GiB 剩余磁盘;8443 已占用,Docker 尚未安装。安装 Docker 前核对其网络规则影响,部署后复核原服务。`loop.liuvv.com` 在本次核查时尚无 DNS 记录;公网 80/443 可达性尚未由独立外部网络证明。 diff --git a/cmd/carry/main.go b/cmd/carry/main.go index 966c9af..766d1c9 100644 --- a/cmd/carry/main.go +++ b/cmd/carry/main.go @@ -39,6 +39,7 @@ Commands: Save secrets separately with 'secret save NAME KEY --stdin' Existing projects keep their bound provider for all subsequent operations rebind NAME --source DIR Change the bound source directory after verifying it + env NAME KEY VALUE VPS only: change a non-secret application variable list status NAME Live read-only ownership, account plan or VPS container checks check NAME GET /healthz; also /readyz when Neon is bound; no business writes @@ -282,6 +283,33 @@ func run(ctx context.Context, args []string) error { return errors.New("registered project not found") } switch args[0] { + case "env": + if len(args) != 4 || p.Provider != "vps" || !validVPSEnv(map[string]string{args[2]: args[3]}) { + return errors.New("use env NAME NON_SECRET_KEY VALUE for a VPS binding") + } + unlock, err := store.lock(p.Name) + if err != nil { + return err + } + defer unlock() + ops, err := store.operations(p.Name) + if err != nil { + return err + } + for _, op := range ops { + if !op.terminal() { + return errors.New("reconcile the unfinished operation before changing environment") + } + } + if p.VPSEnv == nil { + p.VPSEnv = map[string]string{} + } + p.VPSEnv[args[2]] = args[3] + if err = atomicJSON(filepath.Join(store.Root, "projects", p.Name+".json"), p); err != nil { + return err + } + output(p.VPSEnv) + return nil case "rebind": f := flag.NewFlagSet("rebind", flag.ContinueOnError) source := f.String("source", "", "new source directory") From 8e83ecd9499f2dd4b4cf69a66e18d5501851f3e5 Mon Sep 17 00:00:00 2001 From: unix2dos <22365670+unix2dos@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:07:55 +0800 Subject: [PATCH 6/8] docs: record Loop VPS acceptance boundaries --- PRODUCT.md | 2 ++ validation/VPS-RESULTS.md | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 validation/VPS-RESULTS.md diff --git a/PRODUCT.md b/PRODUCT.md index f93fe1c..c20d9e2 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -57,3 +57,5 @@ Carry 是本地运行的部署与结果核对工具。用户已有的编码 Agen - **其他主机与平台**:只有单机路径在 Loop 及至少一个外部开发者项目上可复现,并且现有工具仍留下明确痛点时,再扩展供应商、原生发布或团队权限。 当前 Vercel、Railway、Neon Alpha 与其验证结论继续见 [Alpha 说明](docs/ALPHA.md)、[本地结果](validation/RESULTS.md)、[云端结果](validation/CLOUD-RESULTS.md)。它们不是上述 VPS 路径已经完成的证据。 + +首个 VPS 项目的实际结果及剩余边界见 [Loop → DMIT 验收](validation/VPS-RESULTS.md)。 diff --git a/validation/VPS-RESULTS.md b/validation/VPS-RESULTS.md new file mode 100644 index 0000000..97f34df --- /dev/null +++ b/validation/VPS-RESULTS.md @@ -0,0 +1,19 @@ +# Loop → DMIT 首次公开部署验收 + +日期:2026-09-25。范围:作者已有 DMIT VPS 上的一个 Loop Web 应用,公开域名 `loop.liuvv.com`。这是单机、单项目验证,不代表数据库、对象存储或其他用户服务器已受支持。 + +## 已完成的证据 + +- 源码:Loop 本地提交 `a8f89b3e71f77898965b5d277e5878631a4588a0`;Carry 本地提交 `b32ebd0`。Carry 最终发布操作 `1790344803430908000-ca9c36e4537af123`,打包源码 SHA-256 为 `879534745dccf6587ad382884ecd4c50e7b09532279b855eaa833bd7c2aca8b9`,远端容器镜像 ID 为 `sha256:9cfb0a71bde19c1232d32458cb7d28951fbaf6c9be4ae0f7dd67791d8ad4c78f`。 +- 发布路径:本机按目标 `linux/amd64` 构建,镜像经 Docker SSH 传到 VPS,Compose 启动应用与 Caddy。首次发布因 Carry 暂存源码目录权限导致非 root Loop 无法读取 `web/dist`;共用打包逻辑修复后,容器回读 `READY`。再次发布后已有持久卷标记仍在,说明容器换版未清空挂载目录。 +- 公开入口:Cloudflare 与 Google DNS 均返回 DMIT 的 A 记录;Caddy 日志显示证书取得成功。本机与服务器分别以正常 TLS 校验访问 `https://loop.liuvv.com/healthz` 返回 200;Carry `check loop-vps` 也返回 200。一次网页抓取工具仍报不可访问,未据此推断站点故障。 +- 模型:Z.AI 当前将 `glm-4.5-flash` 标为免费;专用 Key 的直接 Chat Completions 调用返回 200 并产生 `tool_calls`。公开 Loop 上进行 **1 次**真实业务任务:4 次模型请求、3 次工具调用,`read_file` 成功,最终回答引用示例 `agent-loop.md`。`glm-4.7-flash` 在前一轮验证中超时并返回供应商 `1305` 过载,故未用于首发。[Z.AI 价格](https://docs.z.ai/guides/overview/pricing) · [错误码](https://docs.z.ai/api-reference/api-code) +- 匿名隔离:两个独立 Cookie 会话中,访客 B 无法在列表、详情或续接接口获取访客 A 的真实任务;详情和续接均返回 404。公网响应不携带访客归属哈希。完成的任务记录在挂载目录中,自动清理期限设为 7 天。 +- 原有服务:Docker 安装前后及 Loop 发布后,`x-ui` 均为 active,xray 的 8443 监听保留。此检查只覆盖服务与监听,不替代 Mac 客户端的完整代理业务验收。 + +## 尚未证明 + +- 7 天自然到期和全站每日额度耗尽只通过本地自动检查,没有消耗公网额度或等待七天做实测。 +- VPS 镜像回退由假 Docker 读回测试覆盖,尚未在公开实例上做真实回退;当前配置、密钥和数据不会随镜像回退。 +- 未做外部开发者首次使用、持续负载、整机故障恢复或异地备份验收。DMIT 条款要求客户自行备份;当前本机卷只能证明换容器后保留数据。[DMIT 条款](https://www.dmit.io/pages/tos) +- 免费模型价格依据官方当日价格表;未取得供应商账单读回,不能把一次免费模型调用扩大为长期免费或稳定可用承诺。 From 53e773bea7583bdba0fed4c9fd3395495e3f1a05 Mon Sep 17 00:00:00 2001 From: unix2dos <22365670+unix2dos@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:19:44 +0800 Subject: [PATCH 7/8] docs: record public Loop and real rollback acceptance --- PRODUCT.md | 2 +- validation/VPS-RESULTS.md | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/PRODUCT.md b/PRODUCT.md index c20d9e2..5c8af52 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -40,7 +40,7 @@ Carry 是本地运行的部署与结果核对工具。用户已有的编码 Agen 3. 共享的免费模型调用在服务端设全站与单访客上限;资源忙或额度耗尽时明确提示,不以新密钥、付费模型或自动重试绕过上限。 4. 模型首发使用 Z.AI 官方列为免费的 `glm-4.5-flash`:2026-09-25 已用专用 Key 验证 Chat Completions 返回和一次函数工具调用;`glm-4.7-flash` 在同次验证中出现超时和供应商过载。仍须通过 Loop 完成工具回执链与额度边界验收。供应商条款允许 API 集成到面向终端用户的应用,也要求管理终端用户行为。[价格表](https://docs.z.ai/guides/overview/pricing) · [API 条款](https://chat.z.ai/legal-agreement/terms-of-service) 5. 不把 OpenCode Zen 的 `muse-spark-1.3-contributor-free` 接给匿名公众:该免费项有期限,且 OpenCode 当前条款将服务限定为自己的内部使用。[免费项说明](https://opencode.ai/docs/zen/) · [使用条款](https://opencode.ai/legal/terms-of-service) -6. 在 DMIT 上保留已有 xray / 3x-ui。只读预检见到 Debian 13、x86_64、2 vCPU、约 2 GiB 内存、约 35 GiB 剩余磁盘;8443 已占用,Docker 尚未安装。安装 Docker 前核对其网络规则影响,部署后复核原服务。`loop.liuvv.com` 在本次核查时尚无 DNS 记录;公网 80/443 可达性尚未由独立外部网络证明。 +6. 在 DMIT 上保留已有 xray / 3x-ui。首次部署前的只读预检见到 Debian 13、x86_64、2 vCPU、约 2 GiB 内存、约 35 GiB 剩余磁盘;8443 已占用,当时尚未安装 Docker、配置 `loop.liuvv.com` DNS。后续 Docker 安装、DNS/TLS 发布与原服务复核的实测结果见 [VPS 验收](validation/VPS-RESULTS.md)。 ### 验收标准 diff --git a/validation/VPS-RESULTS.md b/validation/VPS-RESULTS.md index 97f34df..3f2b864 100644 --- a/validation/VPS-RESULTS.md +++ b/validation/VPS-RESULTS.md @@ -4,16 +4,18 @@ ## 已完成的证据 -- 源码:Loop 本地提交 `a8f89b3e71f77898965b5d277e5878631a4588a0`;Carry 本地提交 `b32ebd0`。Carry 最终发布操作 `1790344803430908000-ca9c36e4537af123`,打包源码 SHA-256 为 `879534745dccf6587ad382884ecd4c50e7b09532279b855eaa833bd7c2aca8b9`,远端容器镜像 ID 为 `sha256:9cfb0a71bde19c1232d32458cb7d28951fbaf6c9be4ae0f7dd67791d8ad4c78f`。 +- 源码:Loop 最新本地提交 `6004b6a7dc08d1d98f368df7281369f1e3eda07c`;Carry VPS 代码提交 `b32ebd0`。最后一次发布操作 `1790345636801012000-6db2bf0df40500c2`,打包源码 SHA-256 为 `4a70bf2c8816c0ee5f8ab27d95ae957cf5ae52331de280ce9044ac6ca3e10b82`,远端容器镜像 ID 为 `sha256:0bba693394750c4df37cc19e7a5e1336716674ce43b8c365907e3661b90058f6`。 - 发布路径:本机按目标 `linux/amd64` 构建,镜像经 Docker SSH 传到 VPS,Compose 启动应用与 Caddy。首次发布因 Carry 暂存源码目录权限导致非 root Loop 无法读取 `web/dist`;共用打包逻辑修复后,容器回读 `READY`。再次发布后已有持久卷标记仍在,说明容器换版未清空挂载目录。 -- 公开入口:Cloudflare 与 Google DNS 均返回 DMIT 的 A 记录;Caddy 日志显示证书取得成功。本机与服务器分别以正常 TLS 校验访问 `https://loop.liuvv.com/healthz` 返回 200;Carry `check loop-vps` 也返回 200。一次网页抓取工具仍报不可访问,未据此推断站点故障。 +- 公开入口:Cloudflare 与 Google DNS 均返回 DMIT 的 A 记录;Caddy 日志显示证书取得成功。本机与服务器分别以正常 TLS 校验访问 `https://loop.liuvv.com/healthz` 返回 200;Carry `check loop-vps` 也返回 200。真实 Chrome 在 1440×900 打开公开首页和新任务页,公开提示可见、页面脚本错误为 0。网页抓取工具仍报不可访问,未据此推断站点故障。 - 模型:Z.AI 当前将 `glm-4.5-flash` 标为免费;专用 Key 的直接 Chat Completions 调用返回 200 并产生 `tool_calls`。公开 Loop 上进行 **1 次**真实业务任务:4 次模型请求、3 次工具调用,`read_file` 成功,最终回答引用示例 `agent-loop.md`。`glm-4.7-flash` 在前一轮验证中超时并返回供应商 `1305` 过载,故未用于首发。[Z.AI 价格](https://docs.z.ai/guides/overview/pricing) · [错误码](https://docs.z.ai/api-reference/api-code) - 匿名隔离:两个独立 Cookie 会话中,访客 B 无法在列表、详情或续接接口获取访客 A 的真实任务;详情和续接均返回 404。公网响应不携带访客归属哈希。完成的任务记录在挂载目录中,自动清理期限设为 7 天。 +- 换版与回退:Carry 真实回退到上一版镜像 `sha256:9cfb0a71bde19c1232d32458cb7d28951fbaf6c9be4ae0f7dd67791d8ad4c78f`,容器与 HTTPS 检查均成功、任务记录仍在;随后重新发布最新镜像 `sha256:0bba693394750c4df37cc19e7a5e1336716674ce43b8c365907e3661b90058f6`,再次读回 HTTPS 200、记录保留、公开提示可见。回退只切换镜像,沿用当前密钥与数据。 - 原有服务:Docker 安装前后及 Loop 发布后,`x-ui` 均为 active,xray 的 8443 监听保留。此检查只覆盖服务与监听,不替代 Mac 客户端的完整代理业务验收。 ## 尚未证明 - 7 天自然到期和全站每日额度耗尽只通过本地自动检查,没有消耗公网额度或等待七天做实测。 -- VPS 镜像回退由假 Docker 读回测试覆盖,尚未在公开实例上做真实回退;当前配置、密钥和数据不会随镜像回退。 +- 真实回退只验证了此前两版 Loop 镜像;没有覆盖数据库迁移或含破坏性配置变更的回退。当前配置、密钥和数据不会随镜像回退。 - 未做外部开发者首次使用、持续负载、整机故障恢复或异地备份验收。DMIT 条款要求客户自行备份;当前本机卷只能证明换容器后保留数据。[DMIT 条款](https://www.dmit.io/pages/tos) - 免费模型价格依据官方当日价格表;未取得供应商账单读回,不能把一次免费模型调用扩大为长期免费或稳定可用承诺。 +- 旧 Z.AI Key 曾进入对话记录;新专用 Key 的真实调用已经通过,仍需用户确认旧 Key 没有其他用途并撤销旧 Key。 From 44af35bec7fa40badf9485b2a5935800e6e611b7 Mon Sep 17 00:00:00 2001 From: unix2dos <22365670+unix2dos@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:35:56 +0800 Subject: [PATCH 8/8] docs: record final published image and request budget --- validation/VPS-RESULTS.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/validation/VPS-RESULTS.md b/validation/VPS-RESULTS.md index 3f2b864..a25e7a8 100644 --- a/validation/VPS-RESULTS.md +++ b/validation/VPS-RESULTS.md @@ -4,12 +4,12 @@ ## 已完成的证据 -- 源码:Loop 最新本地提交 `6004b6a7dc08d1d98f368df7281369f1e3eda07c`;Carry VPS 代码提交 `b32ebd0`。最后一次发布操作 `1790345636801012000-6db2bf0df40500c2`,打包源码 SHA-256 为 `4a70bf2c8816c0ee5f8ab27d95ae957cf5ae52331de280ce9044ac6ca3e10b82`,远端容器镜像 ID 为 `sha256:0bba693394750c4df37cc19e7a5e1336716674ce43b8c365907e3661b90058f6`。 +- 源码:Loop 最终发布提交 `ed654fb242b84b3e48f5dbbfffd896fb517427fc`;Carry VPS 代码提交 `b32ebd0`。最后一次发布操作 `1790346756569114000-8f75294694f8b6b3`,打包源码 SHA-256 为 `c3f8b40e550be2b0f7fd96f1ba901e68c07a1f27cf54e253ec43fd74f25d3d6c`,远端容器镜像 ID 为 `sha256:ee2a3608be759ea44a1685cdcc191f3f71d07bd5956669da4521e45199ca2e4f`。 - 发布路径:本机按目标 `linux/amd64` 构建,镜像经 Docker SSH 传到 VPS,Compose 启动应用与 Caddy。首次发布因 Carry 暂存源码目录权限导致非 root Loop 无法读取 `web/dist`;共用打包逻辑修复后,容器回读 `READY`。再次发布后已有持久卷标记仍在,说明容器换版未清空挂载目录。 -- 公开入口:Cloudflare 与 Google DNS 均返回 DMIT 的 A 记录;Caddy 日志显示证书取得成功。本机与服务器分别以正常 TLS 校验访问 `https://loop.liuvv.com/healthz` 返回 200;Carry `check loop-vps` 也返回 200。真实 Chrome 在 1440×900 打开公开首页和新任务页,公开提示可见、页面脚本错误为 0。网页抓取工具仍报不可访问,未据此推断站点故障。 -- 模型:Z.AI 当前将 `glm-4.5-flash` 标为免费;专用 Key 的直接 Chat Completions 调用返回 200 并产生 `tool_calls`。公开 Loop 上进行 **1 次**真实业务任务:4 次模型请求、3 次工具调用,`read_file` 成功,最终回答引用示例 `agent-loop.md`。`glm-4.7-flash` 在前一轮验证中超时并返回供应商 `1305` 过载,故未用于首发。[Z.AI 价格](https://docs.z.ai/guides/overview/pricing) · [错误码](https://docs.z.ai/api-reference/api-code) +- 公开入口:Cloudflare 与 Google DNS 均返回 DMIT 的 A 记录;Caddy 日志显示证书取得成功。本机与服务器分别以正常 TLS 校验访问 `https://loop.liuvv.com/healthz` 返回 200;Carry `check loop-vps` 也返回 200。真实 Chrome 在 1440×900 打开公开首页和新任务页,公开提示可见、新访客默认每轮 6 次请求、页面脚本错误为 0。网页抓取工具仍报不可访问,未据此推断站点故障。 +- 模型:Z.AI 当前将 `glm-4.5-flash` 标为免费;专用 Key 的直接 Chat Completions 调用返回 200 并产生 `tool_calls`。公开 Loop 的首次真实任务以 4 次模型请求、3 次工具调用完成并引用示例 `agent-loop.md`。变基后另一轮同类任务因模型前两次参数错误,在第 4 次请求达到本轮上限;将该轮上限调到 6 后,新任务以 5 次模型请求、4 次工具调用完成。最终镜像已显示 6 次默认上限,模型传输逻辑与完成任务时一致;未为最终镜像再次消耗供应商额度。`glm-4.7-flash` 验证中超时并返回供应商 `1305` 过载,故未用于首发。[Z.AI 价格](https://docs.z.ai/guides/overview/pricing) · [错误码](https://docs.z.ai/api-reference/api-code) - 匿名隔离:两个独立 Cookie 会话中,访客 B 无法在列表、详情或续接接口获取访客 A 的真实任务;详情和续接均返回 404。公网响应不携带访客归属哈希。完成的任务记录在挂载目录中,自动清理期限设为 7 天。 -- 换版与回退:Carry 真实回退到上一版镜像 `sha256:9cfb0a71bde19c1232d32458cb7d28951fbaf6c9be4ae0f7dd67791d8ad4c78f`,容器与 HTTPS 检查均成功、任务记录仍在;随后重新发布最新镜像 `sha256:0bba693394750c4df37cc19e7a5e1336716674ce43b8c365907e3661b90058f6`,再次读回 HTTPS 200、记录保留、公开提示可见。回退只切换镜像,沿用当前密钥与数据。 +- 换版与回退:Carry 真实回退到上一版镜像 `sha256:9cfb0a71bde19c1232d32458cb7d28951fbaf6c9be4ae0f7dd67791d8ad4c78f`,容器与 HTTPS 检查均成功、任务记录仍在;随后重新发布较新镜像并完成最终发布 `sha256:ee2a3608be759ea44a1685cdcc191f3f71d07bd5956669da4521e45199ca2e4f`。最终 `/healthz`、浏览器公开提示和 x-ui 服务再次读回正常。回退只切换镜像,沿用当前密钥与数据。 - 原有服务:Docker 安装前后及 Loop 发布后,`x-ui` 均为 active,xray 的 8443 监听保留。此检查只覆盖服务与监听,不替代 Mac 客户端的完整代理业务验收。 ## 尚未证明