--- skill_name: self-hosted-setup skill_description: 在这台机器上逐步部署自托管的 Contextify Cloud 服务器,并在关键决策处暂停。 title: "自托管 Contextify:智能体配置手册" --- [English](https://contextify.sh/docs/self-hosted/agent-setup.md) | [Deutsch](https://contextify.sh/de/docs/self-hosted/agent-setup.md) | [简体中文](https://contextify.sh/zh-hans/docs/self-hosted/agent-setup.md) # 自托管 Contextify:智能体配置手册 你正在帮助用户在他们自己运维的机器(Mac mini、Linux 主机或私有 tailnet(Tailscale 私有网络)中的主机)上部署他们自己的 **Contextify Cloud** 服务器。Contextify Cloud 会同步用户的 Claude Code 和 Codex 历史记录,让他们可以跨机器搜索。这是免费的单用户 **Personal Self-Hosted**(个人自托管版)部署,源码可用,采用 Functional Source License, Version 1.1, Apache 2.0 Future License(FSL-1.1-Apache-2.0)许可证。 在用户的 shell 中按从上到下的顺序执行本手册,每次只做一步。如果你在中断后被再次调用,请先运行“恢复检查”。 ## 操作约定(先读,全程遵守) - **两类指令。** **[YOU RUN]** 表示由你在用户的 shell 中运行的命令。**[USER DOES]** 表示由用户亲自执行的操作(打开浏览器页面、使用 Mac 应用,或在提示符处输入机密信息)。遇到 [USER DOES] 操作时,引导用户完成并等待;不要自己尝试。 - **绝不打印机密信息。** 不要把 API 密钥、数据库密码或配置令牌(setup token)回显到屏幕上。这个会话的对话记录本身也会由 Contextify 建立索引,所以打印出来的机密信息会变成可搜索的历史记录。生成机密信息后直接写入本地 `.env` 文件,不要打印其值,并执行 `chmod 600 .env`。 - **绝不在 `.env` 中写入一行 `$(...)` 命令。** Docker Compose 按字面读取 `.env`,不会执行其中的命令。在写入 `.env` 的 shell 命令中用 `$(...)` 生成值没有问题;但 `.env` 里出现字面的 `$(...)` 则不行。 - **遇到每个 [ASK ME] 都要暂停。** 在以下节点停下来询问用户,没有得到答复就不要继续:服务器主机名(第 1 步)、入口方式以及任何系统级网络变更(第 2 步),以及创建所有者账号之前(第 2 步)。 - **默认保持私有(fail closed,故障时默认拒绝访问)。** 这台服务器保存着用户全部已建立索引的历史记录。唯一认可的入口方式是 Tailscale,它只在用户的私有 tailnet 内部可达。不要把服务器暴露到公共互联网。公共反向代理只是一个高级的、风险自负的例外:没有单独的 [ASK ME],就绝不能这样做;在那次询问中,用户必须接受登录页面会变得可从互联网访问,并且 TLS、身份验证和速率限制的加固都由他们自己负责。 - **不要越界。** 只在克隆下来的项目目录和 Docker 内工作。不要把用户的数据发送到任何地方,也不要推送或发布任何内容。系统级网络操作(Tailscale、代理)只能在第 2 步的 [ASK ME] 之后执行。 - **出现任何失败就停下。** 展示错误,并查阅下文的“故障排查”。如果那里没有列出解决方法,先说明你的计划,得到用户同意后再运行。未经询问,绝不使用 `sudo`、安装系统软件包、更改端口、编辑 Docker 配置或修改用户组。 - **遵守机密信息防护检查。** 首次启动前替换自带的默认值。对于已有的数据库,更改凭据前先按下文的恢复流程操作。 ## 恢复:如果你是被再次调用,先运行这一步 如果用户在中断后重新开始,不要重做已完成的工作,也不要覆盖现有状态。先弄清当前进度(**不要打印任何机密值**),然后从第一个未完成的阶段继续: 1. `contextify-cloud-self-hosted` 目录是否已经克隆?如果是,不要再次 `git clone`,直接 `cd` 进去。 2. `.env` 是否存在?写入之前,用第 1 步的初始化脚本校验全部三个机密赋值。它会拒绝重复项,并保留已确立的值。 3. 三个机密值是否都非空,且自带的默认值都已替换?初始化脚本会在不显示值的情况下检查这一点。如果数据库存储已存在,或其状态不确定,在脚本停止时按恢复流程处理。 4. `.env` 的权限模式是否为 `600`?(`ls -l .env`。) 5. `ALLOWED_ORIGINS`、`EMAIL_BASE_URL`、`INVITATION_BASE_URL` 是否已设为用户的 HTTPS 源(origin),而不是 localhost 默认值? 6. 服务栈是否已启动?`docker compose -f docker-compose.selfhosted.yml ps`。 7. 服务是否健康?`curl -sS http://127.0.0.1:8443/api/v1/health`。 8. HTTPS 入口是否已就位,所有者账号是否已经存在(所有者存在后,`/setup` 页面会返回 410)? 从第一个尚未完成的阶段继续。如果 `.env` 已存在但不完整(仍有占位值,或缺少配置令牌),完成第 1 步中 `.env` 相关的工作,而不是重新复制 `.env.example`。 ## 你要做的事(先告诉用户) 共四步:运行服务器、加固安全、让 Contextify 连接到它、验证搜索和跨会话检索。你会在关键选择处暂停询问,所有操作都在用户的机器上运行。 ## 第 1 步:预检、选择主机名、运行服务器 **[YOU RUN] 预检工具:** 必须具备 `git`、`python3`(3.8 或更高版本)、`curl`、`docker` 和 `docker compose`(`docker --version`、`docker compose version`、`git --version`、`python3 --version`、`curl --version`)。如果缺少任何一个,停下来询问用户想怎样安装;不要替他们选择安装方式。 **[ASK ME] 确定用户的各台机器访问这台服务器时使用的主机名**(只写主机名,不带协议)。它会固化到服务器配置和 TLS 证书中,所以要在写入任何配置之前选定。Tailscale HTTPS 主机名(`your-host.your-tailnet.ts.net`)最简单:名称稳定,证书有效,而且只在用户的私有 tailnet 内可用。记下这个主机名(例如 `hive.example.ts.net`);HTTPS 源就是 `https://`(如果用户输入时带了协议,不要重复添加)。 **[YOU RUN]** 在用户指定的目录中操作(不要放在另一个 git 仓库内)。如果 `contextify-cloud-self-hosted` 已经在那里,不要再次克隆。否则: ``` git clone https://github.com/PeterPym/contextify-cloud-self-hosted.git cd contextify-cloud-self-hosted ``` 仅当 `.env` 尚不存在时,才从 `.env.example` 创建 `.env`。创建时使用 `600` 权限模式;保留已存在的文件: ```sh if [ ! -e .env ] && [ ! -L .env ]; then (umask 077; set -C; cat .env.example > .env) fi ``` **[YOU RUN]** 在初始化缺失或默认的数据库密码之前,先确认数据库存储的状态。阅读 `docker-compose.selfhosted.yml`,找出数据库挂载,以及 `.env` 或 shell 中的任何卷名覆盖。用 `docker context show` 和 `docker volume ls` 核对目标 Docker 守护进程和确切的卷。容器已停止或已删除,并不代表它的卷不存在。如果卷已存在、使用了绑定挂载、检查失败或历史不确定,都应视为 `existing` 或 `unknown`。 仅在确认目标卷不存在、且不会挂载任何已有数据库之后,才能为下面的命令设置 `CONTEXTIFY_DB_STATE=uninitialized`。每次都先重新检查,再使用只作用于该命令的前缀 `CONTEXTIFY_DB_STATE=uninitialized python3`;绝不要 export 这个断言。否则不要设置该变量(即 `unknown`),或将其设为 `existing`。每次只运行一个初始化脚本,运行时服务栈须处于停止状态,且没有同时编辑 `.env` 的操作。使用本手册之前,先清除 shell 中对这三个机密键的覆盖,让 Compose 使用 `.env` 中的值。 初始化脚本会在写入前检查整个文件的赋值结构。它只为缺失或为空的机密键生成值,并替换两个自带的默认值。已有的非默认赋值会逐字节保持不变。它接受单行 `KEY=value` 赋值,包括带引号的字面机密值和行内注释。如果三个机密赋值中出现不支持的字面语法或变量插值,脚本会停下来等待本地检查,且不显示任何值。其他赋值保留原值,包括插值。 ```sh python3 - <<'PY' import os from pathlib import Path import re import secrets import stat import tempfile def stop(message): raise SystemExit(message + " No secret values displayed; .env unchanged.") path = Path('.env') spec = {'API_SECRET_KEY': (32, 'dev-secret-change-me'), 'DB_PASSWORD': (20, 'contextify'), 'CONTEXTIFY_SELF_HOSTED_SETUP_TOKEN': (32, None)} if any(key in os.environ for key in spec): stop('Clear shell overrides for the three secret keys first.') if path.is_symlink() or not path.is_file(): stop('Expected a regular .env file; create it privately from .env.example if absent.') original = path.read_bytes() try: lines = original.decode('utf-8').splitlines(keepends=True) except UnicodeError: stop('Expected UTF-8 .env text.') found = {} for index, line in enumerate(lines): if not line.strip() or line.lstrip().startswith('#'): continue assignment = re.fullmatch(r'([A-Za-z_][A-Za-z0-9_]*)=([^\r\n]*)[\r\n]*', line) if not assignment: stop('Unsupported .env assignment; review syntax locally.') key, raw = assignment.groups() # Multiline values could hide assignments; require single-line values throughout. raw = raw.strip() if raw.startswith(("'", '"')): quoted = re.fullmatch(r"(['\"])(.*?)\1(?:\s+#.*)?", raw) if not quoted: stop('Unsupported quoted value; use single-line assignments.') if key not in spec: continue if key in found: stop('Duplicate secret key: ' + key + '. Resolve it using trusted credentials.') value = quoted.group(2) if raw.startswith(("'", '"')) else re.split(r'\s+#', raw, 1)[0].strip() if any(char in value for char in '$\\\'"') or any(char.isspace() for char in value): stop('Unsupported secret syntax for ' + key + '; review the literal value locally.') found[key] = (index, value) missing = [key for key, (_, default) in spec.items() if key not in found or not found[key][1] or found[key][1] == default] if 'DB_PASSWORD' in missing and os.environ.get('CONTEXTIFY_DB_STATE', 'unknown') != 'uninitialized': stop('Database storage is existing or unknown. Follow the database credential recovery path below.') for key in missing: replacement = key + '=' + secrets.token_hex(spec[key][0]) + '\n' if key in found: lines[found[key][0]] = replacement else: if lines and not lines[-1].endswith(('\n', '\r')): lines[-1] += '\n' lines.append(replacement) updated = ''.join(lines).encode('utf-8') if updated == original: path.chmod(stat.S_IRUSR | stat.S_IWUSR) else: # mkstemp creates a private file beside .env; replace only after a complete write. fd, temporary = tempfile.mkstemp(prefix='.env.', dir='.') try: with os.fdopen(fd, 'wb') as output: os.fchmod(output.fileno(), stat.S_IRUSR | stat.S_IWUSR) output.write(updated) output.flush() os.fsync(output.fileno()) os.replace(temporary, path) finally: if os.path.exists(temporary): os.unlink(temporary) print('Secret initialization complete; .env mode 600. Values preserved or initialized without display.') PY ``` **数据库凭据恢复:** 如果存储已存在或其历史不确定,保持卷和 `.env` 不动。通过用户的本地编辑器,从用户可信的配置或备份中恢复已确立的 `DB_PASSWORD`。如果数据库是用默认密码初始化的,与用户一起安排一次有备份的 PostgreSQL 密码更改,然后更新 `.env` 使其一致。仅编辑 `.env` 不会更改 PostgreSQL 的密码。如果无法找回正确的密码,就停下来,转入数据库恢复。绝不要为了完成这一步而删除或重建卷。 对于重复的键,先让用户根据可信配置消除歧义,然后再重新运行。保留已确立的值,而不是碰巧出现在最后的那个赋值。注意事项: - `API_SECRET_KEY` 不能保留为 `dev-secret-change-me`;否则服务器会拒绝启动。 - **首次启动前设置好 `DB_PASSWORD`。** PostgreSQL 会把密码保存在它的数据卷中。恢复配置流程时要保留这个密码。 - `CONTEXTIFY_SELF_HOSTED_SETUP_TOKEN` 不在 `.env.example` 中;没有它,Compose 无法启动。它控制一次性所有者配置页面的访问。 **[YOU RUN]** 把 `.env` 中的 URL 值设为本步骤开头确定的 HTTPS 源(这些不是机密信息,正常编辑即可): - `ALLOWED_ORIGINS`:将示例值整体替换为 `https://`。 - `EMAIL_BASE_URL` 和 `INVITATION_BASE_URL`:都设为 `https://`。 - 保留 `SELF_HOSTED=true`。保留 `DATABASE_URL` 这一行(这个 Compose 文件会忽略它)。邮件服务商是可选的:登录使用密码;当 `RESEND_API_KEY` 为空时,身份验证邮件不会发出,而是写入服务器日志。 **[YOU RUN]** 启动服务栈(首次运行会构建 API 镜像,需要几分钟): ``` docker compose -f docker-compose.selfhosted.yml up -d --build ``` **[YOU RUN]** 确认本地健康状态。API 默认只绑定在回环地址 `127.0.0.1:8443` 上,首次启动时会运行数据库迁移,所以请等待并重试,最多 1 分钟: ``` curl -sS http://127.0.0.1:8443/api/v1/health ``` 健康的服务器会返回 `{"status": "ok", "self_hosted": true}`。如果一直失败,检查 `docker compose -f docker-compose.selfhosted.yml logs api --tail 50`。等 HTTPS 在真实主机名上就位后,再在第 2 步创建所有者账号。 ## 第 2 步:加固安全(Tailscale),然后创建所有者账号 API 只监听回环地址。在第 1 步选定的主机名上为它加一层 HTTPS,这样证书能证明该主机名,服务器也保持私有。 **[ASK ME] 在任何系统级网络变更之前。** 确认入口方式,并在改动项目目录之外的任何东西之前获得用户批准。 **Tailscale(认可的路径,仅限 tailnet):** 1. 前提:用户已在 Tailscale 管理控制台中为其 tailnet 启用 MagicDNS 和 HTTPS 证书,并且这台机器已加入该 tailnet(`tailscale status`)。 2. **[YOU RUN]** 确认版本和语法(`serve` CLI 在不同版本间有过变化):`tailscale version` 和 `tailscale serve --help`。 3. **[YOU RUN]** 在后台(持久运行、不阻塞)通过 tailnet HTTPS 主机名提供回环 API。认可的形式是将 HTTPS 代理到 `http://127.0.0.1:8443`;使用你所用版本的 `--help` 显示的写法,例如: ``` tailscale serve --bg http://127.0.0.1:8443 ``` 使用 `tailscale serve`,绝不使用 `tailscale funnel`(`funnel` 会把服务器暴露到公共互联网,而这种部署不希望如此)。`--bg` 让它保持运行,而不占用前台。 4. **[YOU RUN]** 用真实主机名验证配置和实际的 HTTPS 路径: ``` tailscale serve status --json curl -sS https:///api/v1/health ``` 健康检查调用必须通过 HTTPS 返回 `{"status": "ok", "self_hosted": true}`,然后才能继续。 **公共反向代理(高级、风险自负,非默认):** 仅在用户明确要求公开暴露服务器时使用。这会让登录页面可从互联网访问,并由用户负责 TLS、身份验证和速率限制的加固。没有单独的 **[ASK ME]** 让用户接受这些后果,就不要走这条路。如果用户接受,就由他们在自己的主机名上终止 TLS(例如使用 Caddy),并代理到 `127.0.0.1:8443`。 **[YOU RUN]** HTTPS 就位后,让配置与最终的源保持一致: - 确认 `.env` 中的 `ALLOWED_ORIGINS`、`EMAIL_BASE_URL` 和 `INVITATION_BASE_URL` 都等于 `https://`。 - 在 `.env` 中设置 `FORCE_SECURE_COOKIES=true`(示例文件默认是 `false`,用于本地 HTTP 验证)。 - 重新运行 `docker compose -f docker-compose.selfhosted.yml up -d`,让容器应用这项更改。 **[ASK ME] 在创建所有者账号之前**,然后按 **[USER DOES]** 由用户完成(两种方式都涉及机密信息,所以由用户执行,而不是你)。Personal Self-Hosted 是单用户的;这个首个账号就是服务器的所有者。先与用户确认邮箱,然后二选一: - 用户在浏览器中打开真实 HTTPS 主机名上的一次性配置页面:`https:///setup?token=THEIR_SETUP_TOKEN`(用户从自己的 `.env` 中读取 `CONTEXTIFY_SELF_HOSTED_SETUP_TOKEN`;不要打印它)。或者 - 用户在自己的终端中运行 `docker compose -f docker-compose.selfhosted.yml exec api python -m contextify_cloud create-admin --email them@example.com`,并在提示时输入密码。不要在命令行中传递 `--password`(它会留在 shell 历史记录和对话记录中)。 **[YOU RUN] 确认所有者已存在**,然后再继续:配置页面现在会返回 410(`curl -sS -o /dev/null -w '%{http_code}' https:///setup`),表示所有者账号已经存在。配置表单上的工作空间名称字段只是这台单用户服务器的一个标签。所有者存在后,配置令牌即失效。 ## 第 3 步:让 Contextify 连接到它 自托管服务器通过 API 密钥连接(浏览器登录流程是托管版提供的便捷方式,不用于自定义服务器 URL)。 **[USER DOES] 生成密钥:** 用户在自托管仪表盘(位于 HTTPS 源)中登录,然后在 Cloud 设置中创建一个具有同步权限的 API 密钥(以 `ctx_` 开头)。仅限搜索的密钥无法上传。 - **Mac 应用([USER DOES]):** 打开“Settings”,进入“Cloud”,选择“Contextify Cloud Self-Hosted”,输入服务器 URL,并粘贴 `ctx_` 密钥。 - **CLI:** `contextify cloud setup --url https://` **[YOU RUN]** 会以交互方式提示输入 API 密钥;由 **[USER DOES]** 在提示处输入(或粘贴)`ctx_` 密钥。这是交互式读取,不是 shell 参数,所以不会留在历史记录中。然后 **[YOU RUN]** `contextify cloud status --json` 和 `contextify cloud sync`。还有一种无头形式(`contextify cloud setup --url ... --key ctx_... --no-input`);如果使用它,应由用户自己运行,这样密钥就不会经过智能体键入的参数。 如果服务器只能在 tailnet 内部访问,请在该 tailnet 中的设备上运行连接命令。 ## 第 4 步:验证搜索和跨会话检索 - **[YOU RUN]** `contextify cloud status --json`,确认最近的项目已经同步。只读仪表盘同样位于 HTTPS 源。 - 在 Mac 应用或仪表盘中搜索一个你确定出现在过往会话中的词。结果来自用户自己的服务器。 - 用 Total Recall(跨会话历史检索)把过去的某个决策或修复带入当前工作:在 Claude Code 中使用 `/total-recall`,在 Codex 中使用 `$total-recall`。 如果刚连接后搜索结果为空,应用会先为本地历史记录建立索引,再同步,所以数据量大的首次运行需要一些时间。这是预期行为,不是故障。 ## 验证清单 - `curl -sS http://127.0.0.1:8443/api/v1/health` 返回 `{"status": "ok", "self_hosted": true}`。 - `curl -sS https:///api/v1/health` 通过 HTTPS 返回相同结果。 - 所有者账号已存在(`/setup` 页面返回 410),并且可以在 HTTPS URL 上登录。 - `contextify cloud status --json` 显示自托管服务器和最近的同步。 - 搜索能返回一个已知的过往结果。 ## 故障排查 - **Compose 无法启动,提示某个变量必须设置:** `.env` 中缺少一个必需的机密值。最常见的是 `CONTEXTIFY_SELF_HOSTED_SETUP_TOKEN`,它不在 `.env.example` 中,必须手动添加。 - **服务器因 API 机密值而拒绝启动:** `API_SECRET_KEY` 仍是 `dev-secret-change-me`。轮换它;不要禁用这项防护检查。 - **更改密码后 API 无法连接数据库:** 使用第 1 步的数据库凭据恢复流程。恢复已确立的密码,或与用户协调一次有备份的 PostgreSQL 密码更改。保留现有的卷。 - **通过回环地址访问 `/setup` 没有反应:** API 只绑定在回环地址上,所以从其他设备无法访问 `http://127.0.0.1:8443/setup`。请使用真实的 HTTPS 主机名,或使用 `create-admin` 命令。 - **`tailscale serve` 似乎卡住,或 HTTPS 健康检查失败:** 确认已在 Tailscale 管理控制台中启用 MagicDNS 和 HTTPS 证书,确认你使用了 `--bg`,并确认 `tailscale serve status --json` 显示了到 `127.0.0.1:8443` 的代理。绝不要改用 `tailscale funnel` 来“修复”可达性;那会把服务器公开暴露。 - **端口 8443 已被占用:** 有其他程序绑定了该端口。询问用户想怎样处理;不要悄悄修改 compose 文件中的端口。 - **Linux 上出现 `docker: permission denied`:** 用户不在 `docker` 组中(该组等同于 root 权限)。更改组成员身份之前先询问;不要条件反射地运行 `sudo docker`。 - **无法解析或访问主机名:** 分别排查 DNS、tailnet 路由、端口可达性,以及证书所标识的主机名。对于 Tailscale,确认这台机器已加入 tailnet,且证书与 `.ts.net` 名称匹配。