fix(cua): preserve recorded signing identities when the keychain is missing

This commit is contained in:
creatixchu committed 2026-10-29 19:41:19 +08:00
1 parent 887356ba1a
commit 2e4fddec5d
5 files changed
+53 -10

No files matched your search

@@ -15,8 +15,8 @@
en: e8cbc43d38786c40
zh: 52b3ba85d98011b9
/dsh-cua-native-helper/build-and-service:
en: 2bc0fe6a9b96897c
zh: c43c5ab96eb3ec4b
en: 98b752544721cd1b
zh: 0148b4b08310246b
/dsh-cua-native-helper/request-protocol:
en: fc8043c425322cdd
zh: cf1edb9a78fda301
@@ -29,7 +29,7 @@ Source checkouts build with `--variant dev`: the application declares bundle ide
Select `sourceSigning: local-identity` in the provider configuration, or pass `--variant dev --signing local-identity` to `build_app.py`, to create or reuse the `DSH Computer Use Dev (local)` certificate. Setup stores it in a dedicated keychain under `~/Library/Application Support/DeepSeek Harness/computer-use-dev-signing` and adds the keychain to the user search list. A file lock serializes setup and removal across worktrees. Each build unlocks the keychain with its adjacent owner-only password file. Any process of the same user can read that password and sign code with the development identity. The designated requirement contains the bundle identifier and certificate instead of the code hash; actual permission retention after rebuilds and across worktrees still requires a manual macOS grant check.
`pnpm --dir packages/experimental/cua-macos run dev-signing:setup` can prepare the identity in advance; it does not change the default signing mode. To remove it, first select `sourceSigning: ad-hoc`, then run `pnpm --dir packages/experimental/cua-macos run dev-signing:remove`. Removal deletes the keychain, its search-list entry and password; the empty lock file remains to serialize later setup. Remove grants separately in System Settings. A missing certificate or invalid identity record fails with instructions to remove and set up again; identity replacement requires new grants. `--identity` overrides `--signing`; release builds ignore `--signing` and keep their existing signing behavior.
`pnpm --dir packages/experimental/cua-macos run dev-signing:setup` can prepare the identity in advance; it does not change the default signing mode. To remove it, first select `sourceSigning: ad-hoc`, then run `pnpm --dir packages/experimental/cua-macos run dev-signing:remove`. Removal deletes the keychain, its search-list entry and password; the empty lock file remains to serialize later setup. Remove grants separately in System Settings. When an identity record exists but its keychain or certificate is missing, the build preserves the record and fails. Invalid records also fail. Recovery requires explicit removal and setup; identity replacement requires new grants. `--identity` overrides `--signing`; release builds ignore `--signing` and keep their existing signing behavior.
Certificate signatures include a signing time. Development builds record the generation produced by each unsigned bundle and certificate fingerprint under `sources/` and reuse it for unchanged content. Ad-hoc builds use a separate record. Explicit certificate names are re-signed because renewal can change the certificate behind a name.
@@ -29,7 +29,7 @@ kind: "package-reference"
在 provider 配置中选择 `sourceSigning: local-identity`,或给 `build_app.py` 传入 `--variant dev --signing local-identity`,即可创建或复用 `DSH Computer Use Dev (local)` 证书。初始化会把证书存入 `~/Library/Application Support/DeepSeek Harness/computer-use-dev-signing` 下的专用钥匙串,并将该钥匙串加入用户搜索列表。文件锁让不同 worktree 的初始化和移除串行执行。每次构建都用旁边仅所有者可读的密码文件解锁钥匙串。同一用户的任何进程都能读取该密码并用开发身份签名。指定要求包含 Bundle ID 和证书,不包含代码哈希;重新编译和切换 worktree 后实际能否保留权限,仍需在 macOS 上手动授权验证。
`pnpm --dir packages/experimental/cua-macos run dev-signing:setup` 可以提前准备身份,但不会改变默认签名方式。移除时先选择 `sourceSigning: ad-hoc`,再运行 `pnpm --dir packages/experimental/cua-macos run dev-signing:remove`。移除命令删除钥匙串、搜索列表条目和密码,保留空锁文件以协调后续初始化。系统设置中的授权需单独删除。证书缺失或身份记录损坏会报错,并提示先移除再初始化;更换身份需要重新授权。`--identity` 覆盖 `--signing`;正式版忽略 `--signing`,沿用原有签名行为。
`pnpm --dir packages/experimental/cua-macos run dev-signing:setup` 可以提前准备身份,但不会改变默认签名方式。移除时先选择 `sourceSigning: ad-hoc`,再运行 `pnpm --dir packages/experimental/cua-macos run dev-signing:remove`。移除命令删除钥匙串、搜索列表条目和密码,保留空锁文件以协调后续初始化。系统设置中的授权需单独删除。已有身份记录但钥匙串或证书缺失时,构建保留记录并报错;记录损坏也会报错。恢复时按提示先移除再初始化,更换身份需要重新授权。`--identity` 覆盖 `--signing`;正式版忽略 `--signing`,沿用原有签名行为。
证书签名包含签名时间。开发构建在 `sources/` 中记录每个未签名应用与证书指纹对应的目录,内容不变时复用该目录。临时签名使用独立记录。显式传入证书名称时会重新签名,因为续期可能改变名称对应的证书。
@@ -112,7 +112,7 @@ def load(directory=DEFAULT_DIRECTORY):
raise RuntimeError(f"Computer Use development signing record is invalid; run {REMOVE_COMMAND}, then {SETUP_COMMAND}")
keychain = keychain_path(directory)
if not keychain.is_file():
raise RuntimeError(f"Computer Use development signing keychain is missing: {keychain}; run {SETUP_COMMAND}")
raise RuntimeError(f"Computer Use development signing keychain is missing: {keychain}; run {REMOVE_COMMAND}, then {SETUP_COMMAND}")
if not listed(keychain, user_keychains()):
raise RuntimeError(f"Computer Use development signing keychain is not on the user keychain search list; run {SETUP_COMMAND}")
# A locked keychain makes codesign show a password dialog, so unlock it explicitly every time.
@@ -154,17 +154,18 @@ def create_certificate(directory, password):
def setup(directory=DEFAULT_DIRECTORY):
"""Create the identity once and keep its keychain on the user search list."""
"""Create an identity only without a record; preserve recorded identities when their keychain is missing."""
with identity_lock(directory):
return setup_locked(directory)
def setup_locked(directory):
keychain = keychain_path(directory)
if record_path(directory).exists() and keychain.is_file():
keychains = user_keychains()
if not listed(keychain, keychains):
security("list-keychains", "-d", "user", "-s", *keychains, str(keychain))
if record_path(directory).exists():
if keychain.is_file():
keychains = user_keychains()
if not listed(keychain, keychains):
security("list-keychains", "-d", "user", "-s", *keychains, str(keychain))
return load(directory)
remove_locked(directory)
directory.mkdir(mode=0o700, parents=True)
@@ -100,6 +100,48 @@ class DevelopmentSigningTests(unittest.TestCase):
self.assertEqual(oct(self.directory.stat().st_mode & 0o777), "0o700")
self.assertEqual(security.keychains, [self.login, str(self.keychain)])
def test_setup_preserves_the_record_when_the_keychain_is_missing(self):
security = self.configure()
record = self.directory / "identity.json"
before = record.read_bytes()
self.keychain.unlink()
with patch.object(signing, "create_certificate") as create:
with self.assertRaisesRegex(RuntimeError, "keychain is missing: .*; run .*remove, then .*setup"):
signing.setup(self.directory)
create.assert_not_called()
self.assertEqual(record.read_bytes(), before)
self.assertFalse(self.keychain.exists())
self.assertEqual(security.commands, [])
def test_build_cli_stops_without_replacing_a_recorded_identity_missing_its_keychain(self):
home = self.root / "home"
directory = home / "Library/Application Support/DeepSeek Harness/computer-use-dev-signing"
directory.mkdir(parents=True)
record = directory / "identity.json"
before = json.dumps({"sha1": "ABC123", "password": "secret-password"}).encode()
record.write_bytes(before)
output = self.root / "applications"
script = """import runpy, sys
from pathlib import Path
from unittest.mock import patch
test_home = Path(sys.argv[2])
Path.home = lambda: test_home
sys.argv = [sys.argv[1], '--variant', 'dev', '--signing', 'local-identity', '--output-directory', sys.argv[3]]
with patch('subprocess.run', side_effect=AssertionError('unexpected native build or signing command')):
runpy.run_path(sys.argv[0], run_name='__main__')
"""
result = subprocess.run([sys.executable, "-c", script, str(Path(signing.__file__).with_name("build_app.py")), str(home), str(output)],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 1)
self.assertEqual(len(result.stderr.splitlines()), 1)
self.assertIn("keychain is missing", result.stderr)
self.assertIn(f"run {signing.REMOVE_COMMAND}, then {signing.SETUP_COMMAND}", result.stderr)
self.assertNotIn("Traceback", result.stderr)
self.assertNotIn("secret-password", result.stderr)
self.assertEqual(record.read_bytes(), before)
self.assertFalse(signing.keychain_path(directory).exists())
self.assertFalse(output.exists())
def test_remove_restores_the_other_search_list_entries_and_deletes_the_password(self):
security = self.configure()
signing.remove(self.directory)