From 2e4fddec5d7aa61addfeb2bb261f82fe0e606b69 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 29 Oct 2026 19:41:19 +0800 Subject: [PATCH] fix(cua): preserve recorded signing identities when the keychain is missing --- .../cua-macos/native/README.i18n.yaml | 4 +- .../experimental/cua-macos/native/README.md | 2 +- .../cua-macos/native/README.zh.md | 2 +- .../cua-macos/native/dev_signing.py | 13 +++--- .../native/tests/test_dev_signing.py | 42 +++++++++++++++++++ 5 files changed, 53 insertions(+), 10 deletions(-) diff --git a/packages/experimental/cua-macos/native/README.i18n.yaml b/packages/experimental/cua-macos/native/README.i18n.yaml index a58b538ab7..58edd3f789 100644 --- a/packages/experimental/cua-macos/native/README.i18n.yaml +++ b/packages/experimental/cua-macos/native/README.i18n.yaml @@ -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 diff --git a/packages/experimental/cua-macos/native/README.md b/packages/experimental/cua-macos/native/README.md index bd6f6b48c3..ca1743475a 100644 --- a/packages/experimental/cua-macos/native/README.md +++ b/packages/experimental/cua-macos/native/README.md @@ -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. diff --git a/packages/experimental/cua-macos/native/README.zh.md b/packages/experimental/cua-macos/native/README.zh.md index fc7de78795..bacb528a94 100644 --- a/packages/experimental/cua-macos/native/README.zh.md +++ b/packages/experimental/cua-macos/native/README.zh.md @@ -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/` 中记录每个未签名应用与证书指纹对应的目录,内容不变时复用该目录。临时签名使用独立记录。显式传入证书名称时会重新签名,因为续期可能改变名称对应的证书。 diff --git a/packages/experimental/cua-macos/native/dev_signing.py b/packages/experimental/cua-macos/native/dev_signing.py index 438c7b54b1..668a2b5372 100644 --- a/packages/experimental/cua-macos/native/dev_signing.py +++ b/packages/experimental/cua-macos/native/dev_signing.py @@ -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) diff --git a/packages/experimental/cua-macos/native/tests/test_dev_signing.py b/packages/experimental/cua-macos/native/tests/test_dev_signing.py index 510e5ce005..bc554e2b6a 100644 --- a/packages/experimental/cua-macos/native/tests/test_dev_signing.py +++ b/packages/experimental/cua-macos/native/tests/test_dev_signing.py @@ -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)