diff --git a/README.ko.md b/README.ko.md index 8fde066..08394c5 100644 --- a/README.ko.md +++ b/README.ko.md @@ -154,6 +154,12 @@ Node.js가 필요하고 `%LOCALAPPDATA%\THOTH\tools`에만 설치하며, 전역 작업 공간 하나는 한 번에 THOTH 프로세스 하나만 사용합니다. 같은 작업 공간에서 두 번째로 시작하면 첫 번째를 닫을 때까지 안내 문구와 함께(종료 코드 3) 멈춥니다. +로컬 작업 공간이 동기화·네트워크·Windows/WSL 공유 폴더로 확인되면 실행을 막습니다(종료 코드 4). +위치를 판단하지 못하면 명시적인 확인이 필요하며(종료 코드 5), 이동식 드라이브에는 경고를 표시합니다. +`thoth workspace`로 위치를 확인하고 [위치 정책과 확인 명령](docs/INSTALL.md#workspace-location-policy)을 읽어주세요. +확인이 필요할 때는 `thoth accept-location --workspace "작업 공간 경로"`를 실행합니다. +이 확인은 안전 인증이 아니며, 알려진 차단 폴더를 허용하지 않습니다. + 로그인은 THOTH 전용 프로필을 사용하며, 다른 프로그램의 기존 로그인 정보는 가져오지 않습니다. Claude 로그인은 그 프로필에서 공식 Claude Code를 실행하고, xAI는 기기 코드 로그인을 사용합니다. 둘 다 가짜 제공자·가짜 실행 파일로만 확인했고(xAI는 Chrome/RPC 흐름도 통과), 실계정 검증은 아직 남아 있습니다. diff --git a/README.md b/README.md index b66989d..124c6c1 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,11 @@ THOTH reports the selected directory at startup and does not move old data autom One workspace is used by one THOTH process at a time; a second start on the same workspace stops with a message (exit code 3) until the first one is closed. +LOCAL workspaces on detected sync, network, or Windows/WSL shared folders are refused (exit 4). +Unclassified locations need explicit confirmation (exit 5); removable drives carry a warning. +Use `thoth workspace` to inspect the selected path and read the +[location policy and confirmation commands](docs/INSTALL.md#workspace-location-policy). + Sign-in uses separate THOTH profiles; existing login information from other programs is not imported. Claude sign-in runs the official Claude Code executable in that profile, and xAI uses a device-code flow. Both were checked only with fake providers or fake executables (xAI also with a diff --git a/SOURCE_MANIFEST.json b/SOURCE_MANIFEST.json index 32adc81..d4fe4d9 100644 --- a/SOURCE_MANIFEST.json +++ b/SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schema_version": "thoth-source-preview-manifest-v1", - "created_at": "2026-10-22T18:37:46.948775+00:00", + "created_at": "2026-10-23T04:39:51.014673+00:00", "license": "MIT", "git_history_included": false, "full_suite_verified": false, @@ -53,13 +53,13 @@ }, { "path": "README.ko.md", - "sha256": "a78940c44f6de6de7e88c31888e9a142d2e9cf36f333f02e39fb017eb9c0c389", - "bytes": 15308 + "sha256": "8e615dcc545c0086d81f84d90f4305bc8a7e7df9475307515bc68c7b65e57602", + "bytes": 15926 }, { "path": "README.md", - "sha256": "5f1ca35c11a5d6402775b966cbcfc8ab192d2df8b17fae66d1adb5888e605dee", - "bytes": 13380 + "sha256": "43f2fd9cebc4acd2278ab5ee57c88005714727c9eb73f6a5327ef46c86a3bcc9", + "bytes": 13723 }, { "path": "THIRD_PARTY_NOTICES.md", @@ -166,10 +166,15 @@ "sha256": "09781c7815f76a1a644d36aa73e725178a37220c9ebe82ff047a357c19d1bb82", "bytes": 401 }, + { + "path": "apps/web/src/api/files.test.ts", + "sha256": "38574d9fb490f3858b936826cf436fe5cd91a9848531104cf61d53f25260e462", + "bytes": 8207 + }, { "path": "apps/web/src/api/files.ts", - "sha256": "53163bbc5bbf9848caa6dbaf36153fe0291b9c8db976af3e12908e02c3aaceff", - "bytes": 735 + "sha256": "c478b9ba85c6e9d503f7e63ad8fa2b73160a05bbd908c632b26bed381e365a4d", + "bytes": 3362 }, { "path": "apps/web/src/api/fixtures/iris-judgment-excerpt.json", @@ -313,8 +318,8 @@ }, { "path": "apps/web/src/api/research.ts", - "sha256": "9b7045efd030f1df237e30ee6c79e4c4fdc782dfc01fe31da9c27af3c4bbcd78", - "bytes": 11565 + "sha256": "c90fe3a06bca765d4306d6d1ca124537674cf4d11276766970b7516916badb77", + "bytes": 11858 }, { "path": "apps/web/src/api/researchFollowup.ts", @@ -681,10 +686,15 @@ "sha256": "a7991fd9ddd8a4a446d891d141fa6262eff0b66723931968423329016218c485", "bytes": 1676 }, + { + "path": "apps/web/src/components/FileConnectionPanel.test.tsx", + "sha256": "c9e1be62564212a573de09fd3ee01b111bea5edc8ffbd51a60e8cb8487f5838f", + "bytes": 7496 + }, { "path": "apps/web/src/components/FileConnectionPanel.tsx", - "sha256": "7ea8c9ad1fa3a0ae944ce34fca0a12672ec56a37e1b709c95f5049f83b847f3c", - "bytes": 4371 + "sha256": "faa6811d1e8c21e7796dcae8ff4c36c4defa9c15748766160fc5bdaa00d2098f", + "bytes": 6774 }, { "path": "apps/web/src/components/FirstRunSetup.test.tsx", @@ -693,8 +703,8 @@ }, { "path": "apps/web/src/components/FirstRunSetup.tsx", - "sha256": "4a817b1bc91b7382764a806f67520f9497adf7d1c5a4e592e6c546be696d3ce8", - "bytes": 15649 + "sha256": "5ec554e2209d00cb509bdaa040e0c5f748d33f92e378a7a44140db225782a4b5", + "bytes": 15791 }, { "path": "apps/web/src/components/HypothesisCompare.test.tsx", @@ -783,8 +793,8 @@ }, { "path": "apps/web/src/components/LiveProjectWorkspace.tsx", - "sha256": "21856fad28f28098317f656557765b8c6317bdffe9dbdf399f51ec92106987da", - "bytes": 43632 + "sha256": "1b156b93c8c570d1c2d04e2f1ddee45dd2f7060bbe23cc7867772d80695495a8", + "bytes": 43764 }, { "path": "apps/web/src/components/LiveProjectWorkspaceConsent.test.tsx", @@ -1141,6 +1151,16 @@ "sha256": "c401c77cbed99e84da691cf51a8ff8c12a750d3479cb31ddd9516fbfe2d4a290", "bytes": 2431 }, + { + "path": "apps/web/src/components/WorkspaceLocationNotice.test.tsx", + "sha256": "2004942e5287061c090ee755e5aa7336ac512a10f2d11733d757b488507096df", + "bytes": 2699 + }, + { + "path": "apps/web/src/components/WorkspaceLocationNotice.tsx", + "sha256": "1df2ab32bfee5878e17ae7464c8fdadbb5f4192357d53078c3c26e9f818d9977", + "bytes": 599 + }, { "path": "apps/web/src/components/XaiDeviceLogin.tsx", "sha256": "e9a53512bb795eb39f848ff732197ca765915f2782ce0d9e237f1225802e60d0", @@ -1693,8 +1713,8 @@ }, { "path": "apps/web/src/i18n/sourceMessages.ts", - "sha256": "411b0ddc410873256b5e92f0e4c860534c7e8822d24b0db646c093b08a1b92fd", - "bytes": 7387 + "sha256": "b7deae32291a516462adc0ec06829738ea8805f196ada71eb492f80e77931854", + "bytes": 7976 }, { "path": "apps/web/src/i18n/stateFormatMessages.ts", @@ -1738,8 +1758,8 @@ }, { "path": "apps/web/src/i18n/uiFormatMessages.ts", - "sha256": "98be0f283f6cad865351f15b4494fc4374a3c2238bbc00e24c75ed2c4ac8e381", - "bytes": 5749 + "sha256": "b3bfa703b1548268a7fc4646f065524999657c5372ce624777394c5a34e4043d", + "bytes": 6125 }, { "path": "apps/web/src/i18n/workspaceDetailMessages.ts", @@ -1793,8 +1813,8 @@ }, { "path": "apps/web/src/workstation.css", - "sha256": "6b5b8bfd96343cc5bdb32b5e09f09be59304923df6e371768e976cc33563af5d", - "bytes": 60405 + "sha256": "663eddc733f6e9c8f99263ce69fd00a9d9c5530b4d0b4e729f6152ca0e36add8", + "bytes": 60738 }, { "path": "apps/web/timeline-preview.html", @@ -1948,18 +1968,18 @@ }, { "path": "docs/INSTALL.md", - "sha256": "1d13de828a5db4a5c42fcd1dc32d56541634ab81b4f96f4f50c293a15300aab7", - "bytes": 18422 + "sha256": "b0afc44cc299a7e95af0f2da8afd8ab6843679bda22d3626b69dc3ce87206cca", + "bytes": 20549 }, { "path": "docs/KNOWN_LIMITATIONS.ko.md", - "sha256": "ce5cc699dc080560681b09f0f7790365e13d57922c0f9d4ba117152b6d297e27", - "bytes": 10028 + "sha256": "6304fc90fc010d75c995fb693170c9060a0d13c327cb0c540e1d6b2594e29dca", + "bytes": 12832 }, { "path": "docs/KNOWN_LIMITATIONS.md", - "sha256": "6d7bbcb9c5b7e392c19b8d44d2dc7bccb03741cae9d524983f856a6efeb690d4", - "bytes": 8541 + "sha256": "fbd264eba5ee88f650d85f04eade4a21ca6b4d45e790d028a1ad7163b8bafe9f", + "bytes": 10605 }, { "path": "docs/KNOWN_TEST_FAILURES.json", @@ -2078,8 +2098,8 @@ }, { "path": "docs/architecture/rpc-method-catalog.md", - "sha256": "58ce46d1e19e14e154d7fde5ac34221339f05f06f30ffe292b35aaed0a451cb8", - "bytes": 16886 + "sha256": "51ff54e967f9a8667abdf0f3036b0acdf9a128258f5fa493205312dfb43b63ce", + "bytes": 17711 }, { "path": "docs/architecture/user-activity-projection.md", @@ -3438,8 +3458,8 @@ }, { "path": "src/thoth/adapters/http/app.py", - "sha256": "a56006788fef587dc53e012d27bdf41fc09c67e6fe93f5198ea758abf7f4e1e9", - "bytes": 20812 + "sha256": "e43e4fc543f0ccebecdfb14840512ac398814618a092e933cf4602756aafd02d", + "bytes": 21097 }, { "path": "src/thoth/adapters/http/async_routes.py", @@ -3993,8 +4013,8 @@ }, { "path": "src/thoth/adapters/storage/bundle.py", - "sha256": "d336ad1fd13776c9028d6a8b6da7a3db14cb27a7b6da873f1302528d9bceac89", - "bytes": 8741 + "sha256": "f598436adb56e55ff0716587949f2d1915db959088d8f59499c42f1932611a05", + "bytes": 9239 }, { "path": "src/thoth/adapters/storage/control_record.py", @@ -4241,6 +4261,21 @@ "sha256": "1873bb91ae61570a71ebc7a3510de7063e0c44d96374578a6e532c5cca156183", "bytes": 3077 }, + { + "path": "src/thoth/adapters/storage/workspace_location.py", + "sha256": "b3bdbd1c749bf286e059b2b2652c2b5a8d1889246132d5d69d80e060711183ec", + "bytes": 7113 + }, + { + "path": "src/thoth/adapters/storage/workspace_location_probes.py", + "sha256": "c64f834b03b8c4300427353c7702f553755d3e37eff5cac70585c024cdcbf556", + "bytes": 10253 + }, + { + "path": "src/thoth/adapters/storage/workspace_location_registry.py", + "sha256": "2f64a88ba467a9c87020969c9451efe2cdbd9de3de320b39546397c271211faa", + "bytes": 3835 + }, { "path": "src/thoth/adapters/storage/workspace_owner.py", "sha256": "7138a59c44a2c25e9d5a79df5112caff82ebb286ab61a6c7c1c36f250ea78989", @@ -4698,8 +4733,8 @@ }, { "path": "src/thoth/application/commands/workspace_setup.py", - "sha256": "2ab1bd17ebc7154ec0c9525cf9d4565eca89596d4c24844dd8b4a2b2b774113c", - "bytes": 7433 + "sha256": "624af5c8342f6f07c6001e9b25e95ccc0c1b17198b2c0f4901bab8f2a095f236", + "bytes": 7715 }, { "path": "src/thoth/application/reducers/__init__.py", @@ -6193,8 +6228,8 @@ }, { "path": "src/thoth/apps/research_entry_composition.py", - "sha256": "517ccfeb83ea21d19f636792308338996149fa2776961b761105684efac988b1", - "bytes": 15272 + "sha256": "ac18506ff0f77304a7365685ce4818891b086b42d6158d711b96c5dd1da26c0e", + "bytes": 15417 }, { "path": "src/thoth/apps/research_history_composition.py", @@ -6223,8 +6258,8 @@ }, { "path": "src/thoth/apps/runtime.py", - "sha256": "b7fbebee2ed9dc395cffa9c8e81efd2d0099c65cd519cb48358ecb22967b07b2", - "bytes": 23384 + "sha256": "38f7a8206fa4a66d51cd267d7ac50092154ba3ce002ba7e27e1ba07cd75598d5", + "bytes": 23406 }, { "path": "src/thoth/apps/runtime_entry.py", @@ -6263,8 +6298,8 @@ }, { "path": "src/thoth/apps/storage_composition.py", - "sha256": "eb6c545eb5f3348c2d052b9f81a47713fb7386abdd9ff530853d0915b4360118", - "bytes": 1902 + "sha256": "b8644b1e7a088262632120be11681b5e0f9e78ecf6c598be6d4a6ebc2a9fafc4", + "bytes": 2137 }, { "path": "src/thoth/apps/test_runtime.py", @@ -6288,18 +6323,28 @@ }, { "path": "src/thoth/apps/workspace_guard.py", - "sha256": "a1006e536515fecb1be9a141854d7d01f0c5c386b02eccc29408102aa1c4a8a9", - "bytes": 1154 + "sha256": "ae797df1136ac33b5d0f4087ae32b7cc17315bc8b497194dd1204aa28f7ce2bc", + "bytes": 2935 + }, + { + "path": "src/thoth/apps/workspace_location_commands.py", + "sha256": "655da07cc73e22174ff373e0a94ed33ad11229ddab8cd1e64b0845ae8cf9ac92", + "bytes": 1910 + }, + { + "path": "src/thoth/apps/workspace_location_presentation.py", + "sha256": "2fc07e493a1612cd618163941dc5690304c4555397806052564034705d5279a7", + "bytes": 3296 }, { "path": "src/thoth/apps/workspace_paths.py", - "sha256": "e476b133bdc0efdb21024818c2d50dab6fc8d3b0b22d217d3e0f4cde8f049216", - "bytes": 1546 + "sha256": "43d2c2fa9ceb9bc049f437cccfeec9efc6de9c8df77a275e4a019eb925c4a008", + "bytes": 1766 }, { "path": "src/thoth/cli.py", - "sha256": "6781d09ba2bb520b6ff95123c117dbedbc155c9b741cf87ade2858811c1e87db", - "bytes": 22529 + "sha256": "7b8f190d125a95ef9ee3c4f38bc31fa50eebc33cf56cb04ca6ac0e23204b3d31", + "bytes": 22618 }, { "path": "src/thoth/domain/__init__.py", @@ -6861,6 +6906,11 @@ "sha256": "9fc8c95234845926af6fa8e71dcbeb687409865f7e2a20dd7e635448b4056fb9", "bytes": 11741 }, + { + "path": "src/thoth/domain/storage_context.py", + "sha256": "609055ac9f97efa3416be462f2ae81bd62c26973788e19aff0997dc7bc07d298", + "bytes": 682 + }, { "path": "src/thoth/domain/task_profile.py", "sha256": "fa9bd013a4bcc5f3e37a0cb46acf4e69a929fa23e38b3272dc45ff33eec24a57", @@ -6916,6 +6966,16 @@ "sha256": "994a94f5223982a1791f66694ed9e1f179d1dbe2b76152831ad6840f95e3a285", "bytes": 1054 }, + { + "path": "src/thoth/domain/workspace_identity.py", + "sha256": "d4d2a17b28bcc19dd247e0e695cf6e1f82019187089b0fe0ca2338f25e77d506", + "bytes": 569 + }, + { + "path": "src/thoth/domain/workspace_location.py", + "sha256": "ba624675eb37c7b322069d458e220cee1554836ea3be265808138b2a82b4b7ad", + "bytes": 1444 + }, { "path": "src/thoth/domain/workspace_setup.py", "sha256": "76658528b2f4dbba2411f7b8f8c124ec26978fe2c9d082bc66e7bd28958269f9", @@ -7231,6 +7291,11 @@ "sha256": "6ea77c241e6c217cabc6ffe7eae1815d4ad6c29e076efa52f2d63929496d6d04", "bytes": 352 }, + { + "path": "src/thoth/ports/workspace_location.py", + "sha256": "599b062b590182351764b3c4e64c01d6bc14747d89a6c394e1f2297d0f7d2414", + "bytes": 244 + }, { "path": "src/thoth/ports/workspace_setup.py", "sha256": "b8cfa26b8d9f5a133ccc6065111512b18fd0f42e653e8643c299638b9f95a68b", @@ -7733,8 +7798,8 @@ }, { "path": "tests/cli_contract_capture.py", - "sha256": "92ef67cfffdc42e045535b5c9c16cb1b1cb535461776cb2d46f0fb92d1663cc6", - "bytes": 1924 + "sha256": "e35e3ce9e618b79fbc8c3b1d1be535fee55ecce344c9266b0e3728860e65f540", + "bytes": 2044 }, { "path": "tests/conftest.py", @@ -7806,11 +7871,21 @@ "sha256": "65b8876f4cc167080139c047024ccc65c9ba7d3ba1f0240cd746f8e9b5b52e9f", "bytes": 1923 }, + { + "path": "tests/contract/test_staged_unconnected_source.py", + "sha256": "8dffaa69d60b5718fb85c93d436ea744d12dbd79a5eff47bb37c4c56945739c3", + "bytes": 4096 + }, { "path": "tests/contract/test_transports.py", "sha256": "2c5cba56f270fdca92adfd3387940e88d92782381c36dae5d60fc053d0b4c6b8", "bytes": 1741 }, + { + "path": "tests/contract/test_workspace_location_boundary.py", + "sha256": "9df2c9ae6b88a3e41c34f00605c3446289a45c552b3a248c9f14c4de92dbcabb", + "bytes": 10208 + }, { "path": "tests/fixtures/action_response_golden.json", "sha256": "8e4a2731fc944cca24939b05b56b06c142f96db58d7b66736d0c0856c9cc3d22", @@ -7823,8 +7898,8 @@ }, { "path": "tests/fixtures/cli_registration_contract.json", - "sha256": "ac658326c23cdf5612865fb310130635a0e022571f8877cf4a429e5c545f9a21", - "bytes": 17956 + "sha256": "b01ded28da9cf07968eddaad3826ed5b1cb88f6e00dc9d7352ba834be07c97e6", + "bytes": 21845 }, { "path": "tests/fixtures/credential_auth_projection_v1.json", @@ -8086,6 +8161,11 @@ "sha256": "350d69e901d11ec9d339dfa6608c34944c25ce3b94f14f8f5e9a5d49400df4a9", "bytes": 44981 }, + { + "path": "tests/fixtures/workspace_location_cases.json", + "sha256": "b5563b0afda3b0c7f94980beaecbc41ccc625a47ef1dd8f38b8d821d5c504e57", + "bytes": 3317 + }, { "path": "tests/integration/behavior_exposure_helpers.py", "sha256": "d7c52e1e7afe3d5158318473cd8fb00d72b25aea6a0230357662d33786038eff", @@ -9988,8 +10068,8 @@ }, { "path": "tests/integration/test_workspace_runtime_lock.py", - "sha256": "e1f2752589771c225cf0a0582137d13813aa6d3e5ad907953ed871413339c62e", - "bytes": 13701 + "sha256": "243e868019b617dc03dc402aa155468692c5fed3cb567c6f829575b3c5fbb621", + "bytes": 13794 }, { "path": "tests/integration/test_xai_workspace_route.py", @@ -11501,6 +11581,16 @@ "sha256": "97e85877dccd5159a0285f53c31cd8b3032249dda33aedc9f5a2f389564c3324", "bytes": 5092 }, + { + "path": "tests/unit/test_workspace_location.py", + "sha256": "d24a2ef9d54728fa94648424969f57a515ad19ffadb5cb59b88f56b00f10d52a", + "bytes": 12421 + }, + { + "path": "tests/unit/test_workspace_location_entry.py", + "sha256": "5fb0016651416ced8132931f2ef671b7eec3c865980279a3e403f1dfd9fc48cf", + "bytes": 2277 + }, { "path": "tests/unit/test_workspace_paths.py", "sha256": "aba44dddb508b4780d33ea0102a42d2ebb393b14ed9379791bebf91faa50d3ef", diff --git a/apps/web/src/api/files.test.ts b/apps/web/src/api/files.test.ts new file mode 100644 index 0000000..ac6bc7c --- /dev/null +++ b/apps/web/src/api/files.test.ts @@ -0,0 +1,176 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { stageFile, type StagedFile } from "./files"; +import { setLocale } from "../i18n/locale"; + +type Progress = { phase: "uploading" | "processing"; loaded: number; total: number | null }; +type Options = { signal?: AbortSignal; onProgress?: (progress: Progress) => void }; +const upload: (file: File, project: string, options?: Options) => Promise = stageFile; +const staged: StagedFile = { relative_path: "uploads/p/a.md", filename: "a.md", media_type: "text/markdown", byte_sha256: "a".repeat(64), bytes: 4 }; +const stalled = "No data was sent for 2 minutes, so the upload stopped. Check your connection and try again. No source was connected."; + +class FakeXHR { + static instances: FakeXHR[] = []; + upload = { onprogress: null as null | ((event: ProgressEvent) => void), onload: null as null | (() => void) }; + onload: null | (() => void) = null; + onerror: null | (() => void) = null; + onabort: null | (() => void) = null; + ontimeout: null | (() => void) = null; + status = 0; + responseText = ""; + timeout = 0; + body: FormData | null = null; + open = vi.fn(); + setRequestHeader = vi.fn(); + send = vi.fn((body: FormData) => { this.body = body; }); + abort = vi.fn(() => this.onabort?.()); + constructor() { FakeXHR.instances.push(this); } + progress(loaded: number, total = 100, lengthComputable = true) { + this.upload.onprogress?.(Object.assign(new Event("progress"), { loaded, total, lengthComputable })); + } + complete(status = 200, text = JSON.stringify(staged)) { + this.status = status; this.responseText = text; this.onload?.(); + } +} + +beforeEach(() => { + vi.useFakeTimers(); setLocale("en"); FakeXHR.instances = []; + vi.stubGlobal("XMLHttpRequest", FakeXHR); + vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); +}); +afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); setLocale("ko"); }); + +function start(options?: Options) { + const result: { value?: StagedFile; error?: Error } = {}; + const file = new File(["data"], "a.md", { type: "text/markdown" }); + const done = upload(file, "project:p", options).then( + value => { result.value = value; }, error => { result.error = error; }, + ); + return { result, done, file }; +} +function xhr() { expect(FakeXHR.instances).toHaveLength(1); return FakeXHR.instances[0]; } +async function expectNoUploadTimer() { + // Drain DOM bookkeeping at the same instant; a leaked 120-second timer would remain. + await vi.advanceTimersByTimeAsync(0); + expect(vi.getTimerCount()).toBe(0); +} + +it("fails at 120 seconds without progress, not at 119.999 seconds", async () => { + const { result } = start(); + await vi.advanceTimersByTimeAsync(119999); + expect(result.error).toBeUndefined(); + await vi.advanceTimersByTimeAsync(1); + expect(result.error?.message).toBe(stalled); + expect(xhr().abort).toHaveBeenCalledTimes(1); + await expectNoUploadTimer(); + setLocale("ko"); + expect(result.error?.message).toBe("파일을 보내는 중 2분 동안 진행이 없어 멈췄습니다. 연결을 확인하고 다시 시도하세요. 연결된 자료는 없습니다."); +}); + +it("allows a slow upload beyond two minutes while bytes keep increasing", async () => { + const updates: Progress[] = []; + const { result, done, file } = start({ onProgress: value => updates.push(value) }); + const request = xhr(); + expect(request.open).toHaveBeenCalledWith("POST", "/files/stage"); + expect(request.setRequestHeader).toHaveBeenCalledWith("x-thoth-project-id", "project:p"); + expect(request.body?.get("file")).toBe(file); + for (const loaded of [10, 20, 30]) { + await vi.advanceTimersByTimeAsync(119000); request.progress(loaded); + expect(result.error).toBeUndefined(); + } + request.upload.onload?.(); request.complete(); await done; + expect(result.value).toEqual(staged); + expect(updates).toContainEqual({ phase: "uploading", loaded: 30, total: 100 }); + expect(updates.at(-1)?.phase).toBe("processing"); + expect(request.timeout).toBe(0); + await expectNoUploadTimer(); +}); + +it("does not renew the deadline for repeated or decreasing byte counts", async () => { + const { result } = start(); const request = xhr(); + await vi.advanceTimersByTimeAsync(110000); request.progress(10); + await vi.advanceTimersByTimeAsync(119000); request.progress(10); request.progress(9); + expect(result.error).toBeUndefined(); + await vi.advanceTimersByTimeAsync(1000); + expect(result.error?.message).toBe(stalled); +}); + +it("tracks increasing bytes even if the total length is unknown", async () => { + const progress = vi.fn(); const { result, done } = start({ onProgress: progress }); + const request = xhr(); + await vi.advanceTimersByTimeAsync(119000); request.progress(10, 0, false); + await vi.advanceTimersByTimeAsync(119000); + expect(result.error).toBeUndefined(); + expect(progress).toHaveBeenLastCalledWith({ phase: "uploading", loaded: 10, total: null }); + request.complete(); await done; +}); + +it("has no processing deadline after upload completion and ignores late progress", async () => { + const { result, done } = start(); const request = xhr(); + await vi.advanceTimersByTimeAsync(119000); request.upload.onload?.(); + await vi.advanceTimersByTimeAsync(180000); request.progress(100); + expect(result.error).toBeUndefined(); + await expectNoUploadTimer(); + request.complete(); await done; + expect(result.value).toEqual(staged); +}); + +it("allows cancellation while the server is processing without a timer", async () => { + const controller = new AbortController(); + const { result, done } = start({ signal: controller.signal }); const request = xhr(); + request.upload.onload?.(); await vi.advanceTimersByTimeAsync(180000); + expect(result.error).toBeUndefined(); + controller.abort(); await done; + expect(result.error?.message).toBe("Upload cancelled. No source was connected."); + expect(request.abort).toHaveBeenCalledTimes(1); + await expectNoUploadTimer(); +}); + +it("cancels the request and ignores a late success callback", async () => { + const controller = new AbortController(); const { result, done } = start({ signal: controller.signal }); + const request = xhr(); const lateLoad = request.onload; + controller.abort(); await done; + expect(request.abort).toHaveBeenCalledTimes(1); + expect(result.error?.message).toBe("Upload cancelled. No source was connected."); + request.status = 200; request.responseText = JSON.stringify(staged); lateLoad?.(); + expect(result.value).toBeUndefined(); + await expectNoUploadTimer(); +}); + +it("does not send a request for an already cancelled signal", async () => { + const controller = new AbortController(); controller.abort(); const { result } = start({ signal: controller.signal }); + await vi.advanceTimersByTimeAsync(0); + expect(result.error?.message).toBe("Upload cancelled. No source was connected."); + expect(FakeXHR.instances).toHaveLength(0); + expect(fetch).not.toHaveBeenCalled(); + await expectNoUploadTimer(); +}); + +it("does not send if cancellation occurs during initial progress notification", async () => { + const controller = new AbortController(); + const { result, done } = start({ signal: controller.signal, onProgress: () => controller.abort() }); + await done; + expect(result.error?.message).toBe("Upload cancelled. No source was connected."); + expect(xhr().send).not.toHaveBeenCalled(); + await expectNoUploadTimer(); +}); + +it.each([[413, '{"detail":"server detail"}', "server detail"], [503, "not json", "503"]])( + "preserves HTTP error handling for %s", async (status, text, expected) => { + const { result, done } = start(); xhr().complete(Number(status), String(text)); await done; + expect(result.error?.message).toContain(String(expected)); + await expectNoUploadTimer(); + }, +); + +it("cleans up after malformed successful JSON", async () => { + const { result, done } = start(); xhr().complete(200, "invalid json"); await done; + expect(result.error).toBeInstanceOf(SyntaxError); + await expectNoUploadTimer(); +}); + +it("cleans up after network errors", async () => { + const { result, done } = start(); xhr().onerror?.(); await done; + expect(result.error).toBeInstanceOf(Error); + await expectNoUploadTimer(); +}); diff --git a/apps/web/src/api/files.ts b/apps/web/src/api/files.ts index cba841d..b1a26d0 100644 --- a/apps/web/src/api/files.ts +++ b/apps/web/src/api/files.ts @@ -1,4 +1,5 @@ import { message as formatMessage } from "../i18n/formatMessages"; +import { UiError } from "../i18n/UiError"; export type StagedFile = { relative_path: string; filename: string; @@ -7,14 +8,80 @@ export type StagedFile = { bytes: number; }; -export async function stageFile(file: File, projectId: string): Promise { - const body = new FormData(); - body.append("file", file); - const response = await fetch("/files/stage", { method: "POST", body, - headers: { "x-thoth-project-id": projectId } }); - if (!response.ok) { - const detail = (await response.json().catch(() => null)) as { detail?: string } | null; - throw new Error(detail?.detail ?? formatMessage("filePrepareFailed", response.status)); - } - return (await response.json()) as StagedFile; +export type UploadProgress = { + phase: "uploading" | "processing"; + loaded: number; + total: number | null; +}; +export type StageFileOptions = { + signal?: AbortSignal; + onProgress?: (progress: UploadProgress) => void; +}; + +const STALL_MS = 120_000; + +export function stageFile(file: File, projectId: string, options: StageFileOptions = {}): Promise { + if (options.signal?.aborted) return Promise.reject(new UiError("sources.fileUploadCancelled")); + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + let timer: ReturnType | undefined; + let settled = false; + let processing = false; + let loaded = 0; + let total: number | null = file.size; + const finish = (error?: unknown, result?: StagedFile) => { + if (settled) return; + settled = true; + clearTimeout(timer); + options.signal?.removeEventListener("abort", cancel); + xhr.onload = xhr.onerror = xhr.onabort = xhr.ontimeout = null; + xhr.upload.onprogress = xhr.upload.onload = null; + if (error !== undefined) reject(error); + else resolve(result!); + }; + const stop = (error: Error) => { if (!settled) { finish(error); xhr.abort(); } }; + const cancel = () => stop(new UiError("sources.fileUploadCancelled")); + const renew = () => { + clearTimeout(timer); + timer = setTimeout(() => stop(new UiError("sources.fileUploadStalled")), STALL_MS); + }; + xhr.upload.onprogress = event => { + if (settled || processing || event.loaded <= loaded) return; + loaded = event.loaded; + total = event.lengthComputable && event.total > 0 ? event.total : null; + renew(); + options.onProgress?.({ phase: "uploading", loaded, total }); + }; + xhr.upload.onload = () => { + if (settled || processing) return; + processing = true; + clearTimeout(timer); + timer = undefined; + options.onProgress?.({ phase: "processing", loaded, total }); + }; + xhr.onload = () => { + if (settled) return; + if (xhr.status < 200 || xhr.status >= 300) { + let detail: { detail?: string } | null = null; + try { detail = JSON.parse(xhr.responseText); } catch { /* Preserve the HTTP fallback. */ } + finish(new Error(detail?.detail ?? formatMessage("filePrepareFailed", xhr.status))); + return; + } + try { finish(undefined, JSON.parse(xhr.responseText) as StagedFile); } + catch (error) { finish(error); } + }; + xhr.onerror = () => finish(new Error(formatMessage("filePrepareFailed", xhr.status))); + xhr.onabort = () => finish(new UiError("sources.fileUploadCancelled")); + xhr.ontimeout = () => stop(new UiError("sources.fileUploadStalled")); + options.signal?.addEventListener("abort", cancel, { once: true }); + try { + xhr.open("POST", "/files/stage"); + xhr.setRequestHeader("x-thoth-project-id", projectId); + const body = new FormData(); + body.append("file", file); + renew(); + options.onProgress?.({ phase: "uploading", loaded, total }); + if (!settled) xhr.send(body); + } catch (error) { finish(error); } + }); } diff --git a/apps/web/src/api/research.ts b/apps/web/src/api/research.ts index e4d3f96..c422e4d 100644 --- a/apps/web/src/api/research.ts +++ b/apps/web/src/api/research.ts @@ -158,7 +158,15 @@ export type ResearchStatus = WorkThread & { }; export type ResearchAdmission = { contract_version: number; project_id?: string; thread_id: string; operation_id?: string; status?: string; request_epoch?: number | null; terminal_reason?: string | null }; +export type WorkspaceLocationInfo = { + classification: "KNOWN_LOCAL" | "KNOWN_SYNC" | "REMOTE_SHARE" | "UNSUPPORTED_BRIDGE" | "REMOVABLE" | "UNKNOWN"; + reason_codes: string[]; + confirmed: boolean; + message_ko: string; + message_en: string; +}; export type WorkspaceReady = { + workspace_location?: WorkspaceLocationInfo; browser_scope_id?: string; response_languages?: ("ko" | "en")[]; response_language_scope?: "REQUEST"; diff --git a/apps/web/src/components/FileConnectionPanel.test.tsx b/apps/web/src/components/FileConnectionPanel.test.tsx new file mode 100644 index 0000000..e58d6a7 --- /dev/null +++ b/apps/web/src/components/FileConnectionPanel.test.tsx @@ -0,0 +1,131 @@ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { FileConnectionPanel } from "./FileConnectionPanel"; +import { setLocale, t } from "../i18n/locale"; + +type Progress = { phase: "uploading" | "processing"; loaded: number; total: number | null }; +type Options = { signal?: AbortSignal; onProgress?: (progress: Progress) => void }; +type Job = { name: string; options?: Options; finish: () => void; fail: () => void }; +const fixture = vi.hoisted(() => ({ jobs: [] as Job[], calls: [] as string[], connectGate: null as Promise | null, failConnect: "" })); +vi.mock("../api/files", () => ({ stageFile: (file: File, _project: string, options?: Options) => new Promise((resolve, reject) => { + fixture.calls.push(`stage:${file.name}`); + const cancel = () => reject(new Error("synthetic upload cancelled")); + options?.signal?.addEventListener("abort", cancel, { once: true }); + fixture.jobs.push({ name: file.name, options, finish: () => { + options?.signal?.removeEventListener("abort", cancel); + resolve({ relative_path: `web/${file.name}`, filename: file.name, media_type: "text/markdown", byte_sha256: "a".repeat(64), bytes: file.size }); + }, fail: () => reject(new Error("synthetic upload stalled")) }); +}) })); +vi.mock("../api/rpcClient", () => ({ rpc: async (_method: string, input: { relative_path: string }) => { + fixture.calls.push(`connect:${input.relative_path}`); + if (fixture.failConnect === input.relative_path) throw new Error("synthetic connect failure"); + if (fixture.connectGate) await fixture.connectGate; + return { value: {}, state: "SUCCEEDED", operation_id: "op:upload" }; +} })); + +let container: HTMLDivElement; +let root: Root; +let client: QueryClient; +beforeEach(async () => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + fixture.jobs = []; fixture.calls = []; fixture.connectGate = null; fixture.failConnect = ""; setLocale("en"); + container = document.createElement("div"); document.body.append(container); + client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + root = createRoot(container); + await act(async () => { root.render(); }); +}); +afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); setLocale("ko"); }); +async function tick() { await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); } +async function select(names: string[]) { + const input = container.querySelector('input[type="file"]')!; + Object.defineProperty(input, "files", { configurable: true, value: names.map(name => new File(["test"], name)) }); + await act(async () => { input.dispatchEvent(new Event("change", { bubbles: true })); }); + const visibility = container.querySelector(`[aria-label="${t("sources.scopeLabel")}"]`)!; + await act(async () => { visibility.value = "PROJECT_SHARED"; visibility.dispatchEvent(new Event("change", { bubbles: true })); }); +} +function button(label: string) { return [...container.querySelectorAll("button")].find(node => node.textContent === label); } +async function start(names: string[]) { + await select(names); + await act(async () => { button(t("composer.attach"))!.click(); }); + await tick(); +} + +it("stages the whole batch before connecting, preserving one-file order", async () => { + await start(["A.md", "B.md"]); + expect(fixture.calls).toEqual(["stage:A.md"]); + await act(async () => fixture.jobs[0].finish()); await tick(); + expect(fixture.calls).toEqual(["stage:A.md", "stage:B.md"]); + await act(async () => fixture.jobs[1].finish()); await tick(); + expect(fixture.calls).toEqual(["stage:A.md", "stage:B.md", "connect:web/A.md", "connect:web/B.md"]); +}); + +it.each(["cancel", "stall"])("connects zero sources when the second staged file ends by %s", async reason => { + await start(["A.md", "B.md", "C.md"]); + await act(async () => fixture.jobs[0].finish()); await tick(); + expect(fixture.jobs.map(job => job.name)).toEqual(["A.md", "B.md"]); + if (reason === "cancel") { + expect(button("Cancel upload")).toBeDefined(); + await act(async () => button("Cancel upload")!.click()); + } else await act(async () => fixture.jobs[1].fail()); + await tick(); + expect(fixture.calls.filter(call => call.startsWith("connect:"))).toEqual([]); + expect(fixture.jobs.map(job => job.name)).toEqual(["A.md", "B.md"]); + expect(button("Cancel upload")).toBeUndefined(); +}); + +it("shows progress and waiting, and hides cancel before connect begins", async () => { + let release!: () => void; + fixture.connectGate = new Promise(resolve => { release = resolve; }); + await start(["A.md"]); + await act(async () => fixture.jobs[0].options?.onProgress?.({ phase: "uploading", loaded: 50, total: 100 })); + expect(container.textContent).toContain("Uploading A.md (50%)"); + await act(async () => fixture.jobs[0].options?.onProgress?.({ phase: "processing", loaded: 100, total: 100 })); + expect(container.textContent).toContain("A.md was sent. The server is processing it…"); + await act(async () => fixture.jobs[0].finish()); await tick(); + expect(fixture.calls).toEqual(["stage:A.md", "connect:web/A.md"]); + expect(button("Cancel upload")).toBeUndefined(); + await act(async () => release()); await tick(); +}); + +it("preserves a newer selection when an earlier batch is cancelled", async () => { + await start(["A.md"]); + await select(["B.md"]); + expect(button("Cancel upload")).toBeDefined(); + await act(async () => button("Cancel upload")!.click()); await tick(); + expect(container.textContent).toContain("B.md"); + expect(fixture.calls).toEqual(["stage:A.md"]); +}); + +it("keeps the existing partial-connect error guidance and cannot cancel a connect", async () => { + fixture.failConnect = "web/B.md"; + await start(["A.md", "B.md", "C.md"]); + for (let index = 0; index < 3; index++) { + await act(async () => fixture.jobs[index].finish()); await tick(); + } + expect(fixture.calls).toEqual(["stage:A.md", "stage:B.md", "stage:C.md", "connect:web/A.md", "connect:web/B.md"]); + expect(button("Cancel upload")).toBeUndefined(); + expect(container.textContent).toContain("synthetic connect failure"); + expect(container.textContent).toContain(t("sources.checkConnected")); +}); + +it("aborts staging on unmount and never connects a late result", async () => { + await start(["A.md"]); + const job = fixture.jobs[0]; + await act(async () => root.unmount()); await tick(); + expect(job.options?.signal?.aborted).toBe(true); + await act(async () => job.finish()); await tick(); + expect(fixture.calls).toEqual(["stage:A.md"]); +}); + +it("keeps cancellation available during processing and connects zero sources", async () => { + await start(["report.pdf"]); + await act(async () => fixture.jobs[0].options?.onProgress?.({ phase: "processing", loaded: 100, total: 100 })); + expect(button("Cancel upload")).toBeDefined(); + await act(async () => setLocale("ko")); + expect(container.textContent).toContain("report.pdf 파일을 다 보냈습니다. 서버가 처리하는 중입니다…"); + await act(async () => button("업로드 취소")!.click()); await tick(); + expect(fixture.calls).toEqual(["stage:report.pdf"]); +}); diff --git a/apps/web/src/components/FileConnectionPanel.tsx b/apps/web/src/components/FileConnectionPanel.tsx index 032aaa3..fc0a0a9 100644 --- a/apps/web/src/components/FileConnectionPanel.tsx +++ b/apps/web/src/components/FileConnectionPanel.tsx @@ -2,12 +2,15 @@ import { message } from "../i18n/formatMessages"; import { t, useTranslation } from "../i18n/locale"; import { Button, Callout, FileInput, HTMLSelect } from "@blueprintjs/core"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useRef, useState } from "react"; -import { stageFile } from "../api/files"; +import { useEffect, useRef, useState } from "react"; +import { stageFile, type StagedFile, type UploadProgress } from "../api/files"; +import { UiError } from "../i18n/UiError"; import { rpc } from "../api/rpcClient"; import { eyebrowLabel } from "./statusLabels"; import { authorityOptions, sourceConnectSchema } from "../api/sourcePolicy"; +type CapturedFiles = { files: File[]; authority: string; visibility: string; version: number; projectId: string }; + export function FileConnectionPanel({ projectId, hosted = false }: { projectId: string; hosted?: boolean }) { useTranslation(); const client = useQueryClient(); @@ -16,31 +19,69 @@ export function FileConnectionPanel({ projectId, hosted = false }: { projectId: const fileInput = useRef(null); const [authority, setAuthority] = useState("UNCLASSIFIED"); const [visibility, setVisibility] = useState(""); - const [progress, setProgress] = useState(""); - const connect = useMutation({ mutationFn: async (captured: {files: File[]; authority: string; visibility: string; version: number}) => { - for (const file of captured.files) { - if (captured.version === selectionVersion.current) setProgress(message("fileStaging", file.name)); - const staged = await stageFile(file, projectId); - await rpc("project/source/connect", sourceConnectSchema.parse({project_id:projectId, relative_path:staged.relative_path, media_type:staged.media_type, - authority:captured.authority, cutoff_state:"ELIGIBLE", security_class:"INTERNAL", - resource_scope:{owner_kind:"PROJECT",visibility:captured.visibility}, version_label:`web-${staged.byte_sha256.slice(0,12)}`}), crypto.randomUUID()); + const [uploadProgress, setUploadProgress] = useState<{name: string; value: UploadProgress} | null>(null); + const [connectingName, setConnectingName] = useState(""); + const [canCancel, setCanCancel] = useState(false); + const upload = useRef(null); + const mounted = useRef(true); + const currentProject = useRef(projectId); + currentProject.current = projectId; + useEffect(() => { + mounted.current = true; + return () => { mounted.current = false; upload.current?.abort(); }; + }, []); + const ownsSelection = (captured: CapturedFiles) => mounted.current && captured.version === selectionVersion.current && captured.projectId === currentProject.current; + const connect = useMutation({ mutationFn: async (captured: CapturedFiles) => { + const controller = new AbortController(); + upload.current = controller; + setCanCancel(true); + const stagedFiles: StagedFile[] = []; + try { + for (const file of captured.files) { + if (controller.signal.aborted) throw new UiError("sources.fileUploadCancelled"); + if (ownsSelection(captured)) setUploadProgress({ name: file.name, value: {phase: "uploading", loaded: 0, total: file.size} }); + const staged = await stageFile(file, captured.projectId, { + signal: controller.signal, + onProgress: value => { if (ownsSelection(captured)) setUploadProgress({ name: file.name, value }); }, + }); + if (controller.signal.aborted) throw new UiError("sources.fileUploadCancelled"); + stagedFiles.push(staged); + } + upload.current = null; + if (mounted.current) setCanCancel(false); + if (ownsSelection(captured)) setUploadProgress(null); + for (const staged of stagedFiles) { + if (ownsSelection(captured)) setConnectingName(staged.filename); + await rpc("project/source/connect", sourceConnectSchema.parse({project_id:captured.projectId, relative_path:staged.relative_path, media_type:staged.media_type, + authority:captured.authority, cutoff_state:"ELIGIBLE", security_class:"INTERNAL", + resource_scope:{owner_kind:"PROJECT",visibility:captured.visibility}, version_label:`web-${staged.byte_sha256.slice(0,12)}`}), crypto.randomUUID()); + } + } finally { + if (upload.current === controller) upload.current = null; + if (mounted.current) setCanCancel(false); } }, onSuccess: async (_result, captured) => { - if (captured.version === selectionVersion.current) { - setFiles([]); setProgress(""); + if (ownsSelection(captured)) { + setFiles([]); setUploadProgress(null); setConnectingName(""); if(fileInput.current)fileInput.current.value=""; } - await Promise.all([client.invalidateQueries({queryKey:["sources",projectId]}),client.invalidateQueries({queryKey:["evidence",projectId]}),client.invalidateQueries({queryKey:["research",projectId]})]); - }, onError: (_error, captured) => { if (captured.version === selectionVersion.current) setProgress(""); } }); + await Promise.all([client.invalidateQueries({queryKey:["sources",captured.projectId]}),client.invalidateQueries({queryKey:["evidence",captured.projectId]}),client.invalidateQueries({queryKey:["research",captured.projectId]})]); + }, onError: (_error, captured) => { if (ownsSelection(captured)) { setUploadProgress(null); setConnectingName(""); } } }); + const progress = uploadProgress ? uploadProgress.value.phase === "processing" + ? message("fileUploadWaiting", uploadProgress.name) + : uploadProgress.value.total ? message("fileUploadProgress", uploadProgress.name, Math.min(100, Math.floor(100 * uploadProgress.value.loaded / uploadProgress.value.total))) + : message("fileStaging", uploadProgress.name) + : connectingName ? message("fileStaging", connectingName) : ""; return

{eyebrowLabel("AUTHORIZED SOURCES")}

{t("sources.fileTitle")}

{t("sources.preserve")}

{t("sources.fileTypes")}{hosted?t("sources.hostedUpload"):t("sources.localUpload")}

-
{ selectionVersion.current += 1; setProgress(""); setFiles(Array.from((event.target as HTMLInputElement).files ?? [])); }} /> +
{ selectionVersion.current += 1; setUploadProgress(null); setConnectingName(""); setFiles(Array.from((event.target as HTMLInputElement).files ?? [])); }} /> -
+ + {canCancel && }
{files.length > 0 &&

{files.map(file=>file.name).join(" · ")}

}{progress &&

{progress}

} {connect.error && {connect.error.message} {t("sources.checkConnected")}} - {connect.isSuccess && connect.variables.version === selectionVersion.current && {t("sources.connected")}} + {connect.isSuccess && ownsSelection(connect.variables) && {t("sources.connected")}}
; } diff --git a/apps/web/src/components/FirstRunSetup.tsx b/apps/web/src/components/FirstRunSetup.tsx index ab2d540..da194cc 100644 --- a/apps/web/src/components/FirstRunSetup.tsx +++ b/apps/web/src/components/FirstRunSetup.tsx @@ -15,6 +15,7 @@ import { providerLabel } from "./statusLabels"; import { OAuthLoginFlow } from "./OAuthLoginFlow"; import { localCredentialWorkspaceId } from "./localCredentialWorkspace"; import { workspaceSetupIssue, workspaceSetupIssueText } from "./workspaceSetupIssue"; +import { WorkspaceLocationNotice } from "./WorkspaceLocationNotice"; const toneIntent: Record = { ready: "success", pending: "primary", action: "none", blocked: "warning", @@ -98,6 +99,7 @@ export function FirstRunSetup({ onDone }: { onDone: () => void }) { return (
+

{hosted ? t("setup.reviewWorkspace") : message("setupStep", currentStep === "model" ? 1 : 2)}

diff --git a/apps/web/src/components/LiveProjectWorkspace.tsx b/apps/web/src/components/LiveProjectWorkspace.tsx index 9940f33..ecf2a05 100644 --- a/apps/web/src/components/LiveProjectWorkspace.tsx +++ b/apps/web/src/components/LiveProjectWorkspace.tsx @@ -21,6 +21,7 @@ import { SourceTimeAdvanced } from "./SourceTimeAdvanced"; import { useCitationGate } from "./SourceCitationGate"; import { cutoffLabel, projectCutoffText } from "../api/sourceTime"; import { FirstRunSetup } from "./FirstRunSetup"; +import { WorkspaceLocationNotice } from "./WorkspaceLocationNotice"; import { WorkspaceInternetConsent } from "./WorkspaceInternetConsent"; import { workspaceSetupIssue, workspaceSetupIssueText, type WorkspaceSetupIssue } from "./workspaceSetupIssue"; import { AutoRetrySwitch } from "./AutoRetrySwitch"; @@ -400,7 +401,7 @@ export function LiveProjectWorkspace() {
Workspace{import.meta.env.VITE_THOTH_TEST_MODE==="true"&&{t("workspace.testMode")}}/{projectsQuery.isFetching?t("workspace.checkingProject"):project?.name??t("workspace.selectProject")}{context.threadId&&<>/{threadsQuery.isFetching?t("workspace.checkingTask"):visibleThreads.find(item=>item.thread_id===context.threadId)?.problem??t("workspace.selectedTask")}}
{project&&projectCutoffText(project.cutoff_at)&&{message("cutoff", projectCutoffText(project.cutoff_at))}}{health.data?.status==="ok"?t("workspace.serverConnected"):health.error?t("workspace.serverFailed"):t("workspace.checkingConnection")}{!hosted&&!executionReady&&{t("workspace.setupNeeded")}}{!hosted&&contextStoreFailed&&{t("workspace.locationUnsaved")}}{missingSelection&&{message("missingSelection", missingSelection)}}
-
setPage(value as Page)}> +
setPage(value as Page)}> {project&&
chooseThread(event.target.value)} options={[{value:"",label:t("workspace.newTask")},...visibleThreads.map(item=>({value:item.thread_id,label:(item.problem||t("workspace.untitledTask")).slice(0,40)}))]}/>
}
{project ? client.invalidateQueries()}> setLocale("ko")); +const removable: WorkspaceLocationInfo = { classification: "REMOVABLE", reason_codes: ["REMOVABLE_DRIVE"], confirmed: false, + message_ko: "이동식 드라이브의 작업 공간입니다. 연결을 유지하고 사용을 마친 뒤 안전하게 제거하세요.", + message_en: "This workspace is on a removable drive. Keep it connected and remove it safely when done." }; + +it("keeps a removable warning visible and follows the display language", async () => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + const container = document.createElement("div"); const root = createRoot(container); + await act(async () => root.render()); + expect(container.querySelector('[role="status"]')?.textContent).toBe(removable.message_ko); + await act(async () => setLocale("en")); + expect(container.textContent).toBe(removable.message_en); + await act(async () => root.unmount()); +}); + +it.each(["KNOWN_LOCAL", "UNKNOWN"] as const)("does not invent a warning or confirmation for %s", async classification => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + const container = document.createElement("div"); const root = createRoot(container); + await act(async () => root.render()); + expect(container.textContent).toBe(""); + await act(async () => root.unmount()); +}); + +it("shows the confirmed unknown warning without a fresh confirmation action", async () => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + const container = document.createElement("div"); const root = createRoot(container); + const location: WorkspaceLocationInfo = {...removable, classification: "UNKNOWN", confirmed: true, + message_ko: "이 폴더가 동기화·네트워크 폴더가 아닌지 확인하지 못했습니다 (PROBE_FAILED). THOTH는 이 위치의 안전을 보증하지 않습니다.", + message_en: "THOTH could not confirm that this folder is not a synced or network folder (PROBE_FAILED). It cannot vouch for it."}; + await act(async () => root.render()); + expect(container.textContent).toBe(location.message_ko); + expect(container.textContent).not.toContain("accept-location"); + await act(async () => root.unmount()); +}); diff --git a/apps/web/src/components/WorkspaceLocationNotice.tsx b/apps/web/src/components/WorkspaceLocationNotice.tsx new file mode 100644 index 0000000..e98c9e4 --- /dev/null +++ b/apps/web/src/components/WorkspaceLocationNotice.tsx @@ -0,0 +1,12 @@ +import { Callout } from "@blueprintjs/core"; +import type { WorkspaceLocationInfo } from "../api/research"; +import { useLocale } from "../i18n/locale"; + +export function WorkspaceLocationNotice({ location }: { location?: WorkspaceLocationInfo }) { + const locale = useLocale(); + if (!location || !(location.classification === "REMOVABLE" + || (location.classification === "UNKNOWN" && location.confirmed))) return null; + return + {locale === "ko" ? location.message_ko : location.message_en} + ; +} diff --git a/apps/web/src/i18n/sourceMessages.ts b/apps/web/src/i18n/sourceMessages.ts index 2fe832a..68cfe86 100644 --- a/apps/web/src/i18n/sourceMessages.ts +++ b/apps/web/src/i18n/sourceMessages.ts @@ -1,4 +1,13 @@ export const sourceMessages = { + "sources.fileUploadStalled": { + "ko": "파일을 보내는 중 2분 동안 진행이 없어 멈췄습니다. 연결을 확인하고 다시 시도하세요. 연결된 자료는 없습니다.", + "en": "No data was sent for 2 minutes, so the upload stopped. Check your connection and try again. No source was connected." + }, + "sources.fileUploadCancel": { "ko": "업로드 취소", "en": "Cancel upload" }, + "sources.fileUploadCancelled": { + "ko": "업로드를 취소했습니다. 연결된 자료는 없습니다.", + "en": "Upload cancelled. No source was connected." + }, "sources.fileTitle": { "ko": "파일 자료 연결", "en": "Connect source files" diff --git a/apps/web/src/i18n/uiFormatMessages.ts b/apps/web/src/i18n/uiFormatMessages.ts index 6a99ce0..e90463f 100644 --- a/apps/web/src/i18n/uiFormatMessages.ts +++ b/apps/web/src/i18n/uiFormatMessages.ts @@ -1,6 +1,8 @@ export const uiFormats = { filesSelected: {ko: (count: number) => `${count}개 선택`, en: (count: number) => `${count} ${count === 1 ? "file" : "files"} selected`}, fileStaging: {ko: (name: string) => `${name} · 업로드 준비 / 구조 파싱`, en: (name: string) => `${name} · Preparing upload / parsing structure`}, + fileUploadProgress: {ko: (name: string, percent: number) => `${name} 보내는 중 (${percent}%)`, en: (name: string, percent: number) => `Uploading ${name} (${percent}%)`}, + fileUploadWaiting: {ko: (name: string) => `${name} 파일을 다 보냈습니다. 서버가 처리하는 중입니다…`, en: (name: string) => `${name} was sent. The server is processing it…`}, restoreProject: {ko: (name: string) => `${name} 되살리기`, en: (name: string) => `Restore ${name}`}, currentSetting: {ko: (state: string) => `현재: ${state}`, en: (state: string) => `Current: ${state}`}, errorCode: {ko: (code: string) => ` 오류 코드: ${code}`, en: (code: string) => ` Error code: ${code}`}, diff --git a/apps/web/src/workstation.css b/apps/web/src/workstation.css index 6a138ae..0a474af 100644 --- a/apps/web/src/workstation.css +++ b/apps/web/src/workstation.css @@ -119,6 +119,9 @@ button:focus-visible, summary:focus-visible, a:focus-visible { outline: 2px soli /* Conversation surfaces use the existing workstation tokens and real record views. */ .conversation-workstation {height:100dvh;min-height:0;overflow:hidden;grid-template-columns:var(--sidebar-width) minmax(0,1fr)} .conversation-workstation {grid-template-rows:48px 40px minmax(0,1fr)} +.conversation-workstation:has(.workspace-location-notice) {grid-template-rows:48px auto minmax(0,1fr)} +.conversation-workstation .workspace-navigation:has(.workspace-location-notice) {flex-wrap:wrap;gap:8px;padding-top:8px;padding-bottom:8px} +.workspace-navigation .workspace-location-notice {flex-basis:100%;overflow-wrap:anywhere} .conversation-workstation .workstation-topbar {min-height:48px;padding:0 20px} .conversation-workstation .workspace-navigation {min-height:40px;padding:0 20px} .conversation-workstation .workspace-navigation .bp6-tab {line-height:40px} diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 4ee09ff..122ddfb 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -221,6 +221,42 @@ and the sign-in commands) still work next to a running server. The lock belongs a crash frees it. It is meant for a local disk; a folder shared between Windows and WSL or synced by a cloud client is not covered. +### Workspace location policy + +Before opening a LOCAL database or taking its lock, THOTH checks the selected folder. Known +cloud-sync folders, network shares (including mapped drives), and Windows/WSL shared paths are +refused with exit code **4**. Move the whole workspace while its backend is stopped; sync only +finished exports. THOTH does not move files or change sync settings for you. + +If the location cannot be classified, non-interactive commands stop with exit code **5**. Inspect +the reason and, only if you choose to use that location, record your confirmation: + +```powershell +.\.venv\Scripts\python.exe -m thoth.cli workspace --workspace "C:\Research\THOTH" +.\.venv\Scripts\python.exe -m thoth.cli accept-location --workspace "C:\Research\THOTH" +``` + +The interactive shell and other interactive CLI commands can ask for that confirmation. +`serve` and `desktop` never prompt: confirm first, then start them again. A lazy runtime opened +through HTTP returns status 503 with `WORKSPACE_LOCATION_BLOCKED` or +`WORKSPACE_LOCATION_UNCONFIRMED` and Korean/English guidance. + +Confirmation is stored in `workspace-location.json`, tied to the workspace identity, +classification, and reason. A changed path or reason requires confirmation again. It never +overrides a known blocked location and is not a safety certification. To forget only this record: + +```powershell +.\.venv\Scripts\python.exe -m thoth.cli accept-location --workspace "C:\Research\THOTH" --forget +``` + +A removable drive with no sync uncertainty can be used with a warning; keep it connected and +remove it safely afterward. Confirmed unknown locations also keep a warning in the setup and +workspace screens. Classification is a snapshot taken when storage opens, not continuous +monitoring. Restart after changing mounts or sync configuration. Hosted review storage does not +use this LOCAL policy. macOS location detection is currently unknown and requires confirmation; +Linux recognizes selected local, network, and Windows bridge mount types. Unrecognized or failed +probes remain unknown. See [limitations](KNOWN_LIMITATIONS.md#workspace-location-detection). + | Stored item | What restarting preserves | | --- | --- | | Projects, sources, evidence, saved results and model preferences | Stored under the workspace. Reopen the same root after restarting the backend. A lost model connection does not hide existing local research. | diff --git a/docs/KNOWN_LIMITATIONS.ko.md b/docs/KNOWN_LIMITATIONS.ko.md index 5537ad2..5e702b6 100644 --- a/docs/KNOWN_LIMITATIONS.ko.md +++ b/docs/KNOWN_LIMITATIONS.ko.md @@ -20,6 +20,26 @@ English: [KNOWN_LIMITATIONS.md](KNOWN_LIMITATIONS.md) 함께 쓰는 폴더나 클라우드 동기화·네트워크 공유로 여러 컴퓨터가 보는 작업 공간은 보호하지 못합니다. 그런 방식으로 같은 작업 공간을 두 곳에서 열지 마세요. 심사용(hosted review) 모드는 이 잠금을 쓰지 않습니다. +## 작업 공간 위치 판정의 한계 + +로컬 모드는 동기화 폴더·네트워크 공유·Windows/WSL 공유 경로로 확인된 위치를 DB 잠금 전에 +막습니다. 판단하지 못한 위치는 명시적으로 확인해야 하며, 이동식 드라이브와 확인한 미분류 +위치에는 경고가 남습니다. [위치 조회와 확인 명령](INSTALL.md#workspace-location-policy)을 참고하세요. +`thoth workspace --workspace "경로"`로 확인하고, 미분류 위치를 사용하기로 결정했다면 +`thoth accept-location --workspace "경로"`를 실행하세요. `--forget`은 확인 기록만 지웁니다. + +Windows 드라이브·클라우드 조회, 파일 속성, 제공자 경로와 동기화 루트 등록 정보를 함께 봅니다. +실제 PC의 읽기 전용 확인에서는 Windows 클라우드 조회만으로 기존 OneDrive 폴더를 잡지 못했고, +알려진 폴더 경로 신호로 차단했습니다. DriveFS의 설정 경로만 있으면 실제 동기화 증거로 +단정하지 않고 미분류로 둡니다. 레지스트리 접근 실패도 미분류입니다. 선택적인 등록 키·값이 +없으면 그 항목에 등록된 경로가 없는 것으로 봅니다. 모든 동기화 프로그램을 탐지하거나 폴더의 +안전을 보증하지는 않습니다. macOS는 현재 미분류이며, Linux는 지정된 mount 종류를 검사합니다. +실제 이동식 디스크·네트워크 마운트·동작 중인 동기화 클라이언트는 집중 주입 시험으로 검증한 것이 아닙니다. + +판정과 화면 경고는 저장소를 열 때의 상태입니다. 이후 mount·동기화 설정 변경을 계속 감시하지 +않으므로 변경 뒤에는 다시 시작하세요. 확인 기록은 정규화된 작업 공간 식별자와 판정 이유에 +묶이며, 알려진 차단 위치를 우회할 수 없습니다. 심사용(hosted review)에는 이 로컬 정책을 적용하지 않습니다. + ## 종료로 중단된 요청의 복구 로컬 모드에서는 실측한 67개 명령에 한해, 같은 멱등 key를 다시 보내면 소유 worker의 종료가 @@ -117,4 +137,10 @@ handler·모델·그 밖의 효과를 다시 실행하지 않습니다. 기록 다시 평가되지 않습니다. - 마지막 시도가 종료성 실패로 끝난 단계는 스스로 다시 실행되지 않습니다. 사용자가 확인(reconcile) 하거나 새 revision을 만들 때까지 실행이 기다립니다. -- 파일 올리기에는 화면 차원의 시간 제한이 없고 서버의 크기 제한만 있습니다. +- 파일을 준비하는 업로드 단계에서는 취소할 수 있습니다. 보낸 바이트가 2분 동안 늘지 않으면 + 화면에서 요청을 중단합니다. 전송이 끝난 뒤 서버 처리 단계에는 자동 기한이 없고 사용자가 + 대기를 취소할 수 있습니다. 진행이 계속되면 전체 시간 제한은 없고, 서버의 64MiB 제한은 그대로입니다. 선택한 파일을 모두 준비한 + 뒤에만 자료 연결을 시작합니다. 취소·실패 뒤에도 준비가 끝난 미연결 파일은 inbox에 남을 수 + 있지만, 준비만으로 자료 목록이나 근거에 등록되지는 않습니다. 자료 연결은 별도 단계이며 + 업로드 취소 버튼으로 취소할 수 없습니다. 실제 배포 환경의 브라우저·프록시 진행 이벤트는 + 아직 검증하지 않았습니다. 서버 수신 기한이나 완성된 미연결 파일의 자동 정리는 추가하지 않았습니다. diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index a2c3a9e..2b8ad20 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -22,6 +22,26 @@ on a local disk. It does not cover a workspace folder shared between Windows and cloud sync client or a network share exposes to several computers; do not open the same workspace from two places that way. The hosted review mode does not use this lock. +## Workspace location detection + +LOCAL startup now refuses detected cloud-sync roots, network shares, and Windows/WSL bridges +before the database lock is taken. Unknown locations need explicit confirmation; removable +drives and confirmed unknown locations retain a warning. See the +[inspection and confirmation commands](INSTALL.md#workspace-location-policy). + +Detection combines Windows drive and cloud queries, file attributes, known provider paths and +registered sync roots. A native cloud query alone did not identify an existing OneDrive folder +in the read-only host check; the known-root signal did. A DriveFS configured mount point alone +is treated as unknown, not proof of an active sync root. Registry access failures are unknown; +absent optional registration entries mean no configured root at that entry. The checks do not +identify every sync client or guarantee that a folder is safe. macOS is currently unknown; +Linux uses selected mount types. Physical removable drives, live network mounts and running +sync-client behavior have not been verified by the focused injected tests. + +The classification and warning describe the storage-open snapshot. THOTH does not monitor later +mount or sync-setting changes. Confirmation is bound to the canonical workspace identity and +reason and cannot override a known block. Hosted review is outside this LOCAL policy. + ## Recovery of a request interrupted by shutdown In LOCAL mode, 67 measured commands can be closed as failed when the same idempotency key is @@ -127,4 +147,11 @@ exist. catalog. - A step whose last attempt ended with a terminal failure is not dispatched again on its own. The execution waits until you reconcile it or create a new revision. -- File upload has no time limit of its own in the interface, only the server's size limit. +- File uploads can be cancelled while staging. The interface aborts after two minutes without + increasing sent bytes. After all bytes are sent, server processing has no automatic deadline and + the user can cancel the wait. Progress that continues has no overall time limit; the server's 64 MiB limit remains. + All files in a selection are staged before any are connected. Cancelled or failed staging can + leave complete, unconnected files in the inbox; staging alone does not add them to sources or + evidence. Connecting sources is a separate step and cannot be cancelled by the upload button. + Browser/proxy progress behaviour has not been verified in a live deployment. There is no new + server-side receiving deadline or automatic cleanup of complete unconnected staged files. diff --git a/docs/architecture/rpc-method-catalog.md b/docs/architecture/rpc-method-catalog.md index c770eba..e5f8794 100644 --- a/docs/architecture/rpc-method-catalog.md +++ b/docs/architecture/rpc-method-catalog.md @@ -7,6 +7,16 @@ Requests use the public JSON-RPC `/rpc` surface. The method classification below ## Request-scoped response language and expected-result views +LOCAL `workspace/ready` may also include `workspace_location`: `classification` (one of +`KNOWN_LOCAL`, `KNOWN_SYNC`, `REMOTE_SHARE`, `UNSUPPORTED_BRIDGE`, `REMOVABLE`, `UNKNOWN`), +`reason_codes`, `confirmed`, `message_ko`, and `message_en`. This optional projection describes +the decision used when storage opened; it does not probe again or certify storage safety. +Confirmed `UNKNOWN` and `REMOVABLE` remain warnings. Custom storage without the optional +location reporter and hosted review may omit the field. Before a lazy LOCAL runtime opens, +a location refusal returns HTTP 503 with `detail.code` of `WORKSPACE_LOCATION_BLOCKED` or +`WORKSPACE_LOCATION_UNCONFIRMED` and `detail.message_ko` / `detail.message_en`. Known blocked +locations cannot be overridden by confirmation. This is a startup error, not a completed RPC. + `thread/start`, `thread/input` and new-revision `thread/steer` with `contract_version=2` accept an optional `response_language` of `ko` or `en`. Omission retains the legacy question-language policy; null, empty and unsupported values are rejected. Explicit selections are stored in `ThreadRequestRevision` 2.1.0; legacy 2.0.0 records are not rewritten. Each new question may choose another language within the same thread. Queued inputs and retries retain the submitted payload and key. Resume uses its target request's language; conflicting overrides are caller-scoped rejections. Omission versus explicit `ko`, and `ko` versus `en`, are different idempotency inputs. `workspace/ready` advertises `response_languages: ["ko", "en"]` and `response_language_scope: "REQUEST"`. Hosted review also supplies an opaque `browser_scope_id` tied to its workspace and authenticated session when present. This is only a browser storage namespace and grants no authority. Local pending records use localStorage; hosted pending records use sessionStorage. Display locale changes make no model call or canonical write. diff --git a/src/thoth/adapters/http/app.py b/src/thoth/adapters/http/app.py index a822986..35acaf3 100644 --- a/src/thoth/adapters/http/app.py +++ b/src/thoth/adapters/http/app.py @@ -36,7 +36,9 @@ mark_workspace_live, ) from thoth.apps.runtime import AppRuntime, create_runtime +from thoth.apps.workspace_location_presentation import location_error_detail from thoth.domain.auth import AuthenticatedActorContext, authenticated_actor_scope +from thoth.domain.workspace_location import WorkspaceLocationError from thoth.ports.auth import HttpAuthenticationPort from thoth.protocol.bus import CommandBus, DispatchTicket from thoth.protocol.jsonrpc import ( @@ -433,6 +435,8 @@ def _resolve_bus(application: FastAPI) -> CommandBus: workspace = Path(os.environ.get("THOTH_WORKSPACE", ".thoth")) try: runtime = create_runtime(workspace) + except WorkspaceLocationError as error: + raise HTTPException(status_code=503, detail=location_error_detail(error)) from error except WorkspaceInUse as error: # Only reached when the server was not started through the CLI (which owns the workspace # first). The path and process id are shown to the caller, never stored. diff --git a/src/thoth/adapters/storage/bundle.py b/src/thoth/adapters/storage/bundle.py index 7d14260..953978f 100644 --- a/src/thoth/adapters/storage/bundle.py +++ b/src/thoth/adapters/storage/bundle.py @@ -42,8 +42,11 @@ from thoth.adapters.storage.thread_runtime import SqliteThreadRuntimeStore from thoth.adapters.storage.threads import SqliteThreadStore from thoth.adapters.storage.transaction import SqliteAtomicUnitOfWork +from thoth.adapters.storage.workspace_location import check_workspace_location from thoth.adapters.storage.workspace_owner import WorkspaceOwnership, acquire_workspace_owner -from thoth.domain.deployment_mode import DeploymentMode, parse_deployment_mode +from thoth.domain.deployment_mode import DeploymentMode +from thoth.domain.storage_context import storage_deployment_mode +from thoth.domain.workspace_location import WorkspaceLocation from thoth.ports.resource_scope import ( ResourceAccessPort, ResourceRecordAccessPort, @@ -55,6 +58,7 @@ class SqliteStoreBundle: def __init__(self, ledger: SqliteLedger, workspace: Path) -> None: self.ledger = ledger self._ownership: WorkspaceOwnership | None = None + self._location: WorkspaceLocation | None = None self.objects = ContentAddressedObjectStore(workspace) self.atomic_uow = SqliteAtomicUnitOfWork(ledger.engine) self.immediate_uow = SqliteAtomicUnitOfWork(ledger.engine, immediate=True) @@ -132,9 +136,15 @@ def evidence_uow( self.ledger.engine, resource_scopes=resource_scopes, fault_injector=fault_injector ) - def hold_workspace(self, ownership: WorkspaceOwnership | None) -> None: + def hold_workspace( + self, ownership: WorkspaceOwnership | None, *, location: WorkspaceLocation | None = None + ) -> None: """Release the workspace together with this bundle.""" self._ownership = ownership + self._location = location + + def workspace_location(self) -> WorkspaceLocation | None: + return self._location def close(self) -> None: try: @@ -155,11 +165,9 @@ class SqliteStoreFactory: def open(self, workspace: Path) -> SqliteStoreBundle: # One THOTH process per local workspace; the hosted review mode keeps one runtime per # session workspace by its own deployment rule and is not locked here. - ownership = ( - None - if parse_deployment_mode() is DeploymentMode.HOSTED_REVIEW - else acquire_workspace_owner(workspace) - ) + hosted = storage_deployment_mode() is DeploymentMode.HOSTED_REVIEW + location = None if hosted else check_workspace_location(workspace) + ownership = None if hosted else acquire_workspace_owner(workspace) try: database = workspace / "db" / "thoth.sqlite3" migrate_sqlite_database(database) @@ -167,7 +175,7 @@ def open(self, workspace: Path) -> SqliteStoreBundle: try: ledger.initialize() bundle = SqliteStoreBundle(ledger, workspace) - bundle.hold_workspace(ownership) + bundle.hold_workspace(ownership, location=location) return bundle except BaseException: ledger.close() diff --git a/src/thoth/adapters/storage/workspace_location.py b/src/thoth/adapters/storage/workspace_location.py new file mode 100644 index 0000000..d4a2fd7 --- /dev/null +++ b/src/thoth/adapters/storage/workspace_location.py @@ -0,0 +1,191 @@ +"""Classify injected location signals and enforce support policy before opening SQLite.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import tempfile +from collections.abc import Callable +from dataclasses import replace +from datetime import UTC, datetime +from pathlib import Path, PurePosixPath, PureWindowsPath + +from thoth.adapters.storage.workspace_location_probes import LocationSignals, probe_workspace +from thoth.domain.workspace_identity import windows_path_key, workspace_identity +from thoth.domain.workspace_location import ( + LocationKind, + WorkspaceLocation, + WorkspaceLocationConfirmation, + WorkspaceLocationError, +) + +CONFIRMATION_FILE = "workspace-location.json" + + +def _contains(path: str, root: str, windows: bool) -> bool: + if windows: + selected, parent = ( + PureWindowsPath(windows_path_key(path)), + PureWindowsPath(windows_path_key(root)), + ) + else: + selected, parent = PurePosixPath(path), PurePosixPath(root) + return selected == parent or parent in selected.parents + + +def _mount_kind(path: str, mountinfo: str) -> tuple[LocationKind, str]: + mounts: list[tuple[str, str]] = [] + try: + for line in mountinfo.splitlines(): + if not line.strip(): + continue + left, right = line.split(" - ", 1) + mount = re.sub(r"\\([0-7]{3})", lambda match: chr(int(match[1], 8)), left.split()[4]) + if _contains(path, mount, False): + mounts.append((mount, right.split()[0])) + except (ValueError, IndexError): + return "UNKNOWN", "MOUNTINFO_INVALID" + if not mounts: + return "UNKNOWN", "MOUNTINFO_UNAVAILABLE" + _mount, kind = max(mounts, key=lambda item: len(PurePosixPath(item[0]).parts)) + if kind in {"9p", "drvfs"}: + return "UNSUPPORTED_BRIDGE", "WINDOWS_LINUX_MOUNT" + if kind.startswith("nfs") or kind in {"cifs", "smb3"}: + return "REMOTE_SHARE", "NETWORK_MOUNT" + if kind in {"ext4", "xfs", "btrfs", "tmpfs", "overlay"}: + return "KNOWN_LOCAL", "LOCAL_MOUNT" + return "UNKNOWN", "FILESYSTEM_UNSUPPORTED_" + kind[:80] + + +def classify_location(signals: LocationSignals) -> WorkspaceLocation: + windows = signals.platform == "win32" + key = windows_path_key(signals.path) if windows else signals.path + reasons = list(signals.errors) + kind: LocationKind = "UNKNOWN" + if windows and any( + key == root or key.startswith(root + "\\") for root in (r"\\wsl$", r"\\wsl.localhost") + ): + kind, reason = "UNSUPPORTED_BRIDGE", "WINDOWS_WSL_SHARE" + elif windows and key.startswith("\\\\"): + kind, reason = "REMOTE_SHARE", "UNC_SHARE" + elif windows and signals.drive_type == "REMOTE": + kind, reason = "REMOTE_SHARE", "NETWORK_DRIVE" + elif signals.cloud == "SYNC": + kind, reason = "KNOWN_SYNC", "CLOUD_SYNC_ROOT" + elif any(_contains(key, root, windows) for root in signals.known_roots): + kind, reason = "KNOWN_SYNC", "PROVIDER_SYNC_ROOT" + elif candidates := tuple( + root for root in signals.candidate_roots if _contains(key, root, windows) + ): + fingerprint = hashlib.sha256(json.dumps(sorted(candidates)).encode()).hexdigest()[:16] + kind, reason = "UNKNOWN", "SYNC_ROOT_CANDIDATE_" + fingerprint + elif signals.errors: + reason = "LOCATION_PROBE_INCOMPLETE" + elif signals.cloud_attributes: + reason = "CLOUD_ATTRIBUTES_UNCONFIRMED" + elif windows: + if signals.cloud != "NOT_UNDER_SYNC_ROOT": + reason = "CLOUD_STATUS_UNKNOWN" + elif signals.drive_type == "REMOVABLE": + kind, reason = "REMOVABLE", "REMOVABLE_DRIVE" + elif signals.drive_type == "FIXED": + kind, reason = "KNOWN_LOCAL", "FIXED_DRIVE_NO_SYNC_SIGNAL" + else: + reason = "DRIVE_TYPE_UNKNOWN" + elif signals.platform.startswith("linux"): + kind, reason = _mount_kind(key, signals.mountinfo) + else: + reason = "PLATFORM_PROBE_UNAVAILABLE" + reasons.append(reason) + return WorkspaceLocation( + kind, + tuple(sorted(set(reasons))), + signals.path, + workspace_identity(signals.path, windows=windows), + ) + + +def inspect_workspace_location( + workspace: Path, *, probe: Callable[[Path], LocationSignals] | None = None +) -> WorkspaceLocation: + return classify_location((probe or probe_workspace)(workspace)) + + +def _confirmed(location: WorkspaceLocation) -> bool: + path = Path(location.canonical_path) / CONFIRMATION_FILE + try: + with path.open("rb") as source: + raw = source.read(65_537) + if len(raw) > 65_536: + return False + value = WorkspaceLocationConfirmation.model_validate_json(raw) + return ( + value.workspace_id == location.workspace_id + and value.classification == location.kind + and value.reason_digest == location.reason_digest + ) + except (OSError, ValueError, TypeError): + return False + + +def check_workspace_location( + workspace: Path, *, probe: Callable[[Path], LocationSignals] | None = None +) -> WorkspaceLocation: + location = inspect_workspace_location(workspace, probe=probe) + if location.blocked: + raise WorkspaceLocationError(location) + if location.kind == "UNKNOWN": + if not _confirmed(location): + raise WorkspaceLocationError(location) + location = replace(location, confirmed=True) + return location + + +def confirm_workspace_location( + workspace: Path, + *, + probe: Callable[[Path], LocationSignals] | None = None, + expected: WorkspaceLocation | None = None, +) -> WorkspaceLocation: + location = inspect_workspace_location(workspace, probe=probe) + if location.blocked: + raise WorkspaceLocationError(location) + if location.kind != "UNKNOWN": + return location + if expected is not None and (location.workspace_id, location.kind, location.reason_digest) != ( + expected.workspace_id, + expected.kind, + expected.reason_digest, + ): + raise WorkspaceLocationError(location) + root = Path(location.canonical_path) + root.mkdir(parents=True, exist_ok=True) + record = WorkspaceLocationConfirmation( + workspace_id=location.workspace_id, + reason_digest=location.reason_digest, + confirmed_at=datetime.now(UTC), + ) + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=root, + prefix=".workspace-location-", + suffix=".tmp", + delete=False, + ) as sink: + temporary = Path(sink.name) + sink.write(record.model_dump_json(indent=2)) + sink.write("\n") + os.replace(temporary, root / CONFIRMATION_FILE) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + return replace(location, confirmed=True) + + +def forget_workspace_location(workspace: Path) -> None: + (workspace.expanduser().resolve() / CONFIRMATION_FILE).unlink(missing_ok=True) diff --git a/src/thoth/adapters/storage/workspace_location_probes.py b/src/thoth/adapters/storage/workspace_location_probes.py new file mode 100644 index 0000000..1a88a52 --- /dev/null +++ b/src/thoth/adapters/storage/workspace_location_probes.py @@ -0,0 +1,258 @@ +"""Read-only, injectable location signals. No sync clients are started or configured.""" + +from __future__ import annotations + +import ctypes +import json +import os +import re +import sys +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from pathlib import Path, PureWindowsPath +from typing import Literal, cast + +from thoth.adapters.storage.workspace_location_registry import registered_sync_roots +from thoth.domain.workspace_identity import windows_path_key + +CloudState = Literal["SYNC", "NOT_UNDER_SYNC_ROOT", "UNKNOWN"] +DriveType = Literal["FIXED", "REMOTE", "REMOVABLE", "UNKNOWN"] + + +@dataclass(frozen=True) +class LocationSignals: + path: str + platform: str + drive_type: DriveType = "UNKNOWN" + cloud: CloudState = "UNKNOWN" + cloud_attributes: bool = False + reparse: bool = False + known_roots: tuple[str, ...] = () + candidate_roots: tuple[str, ...] = () + mountinfo: str = "" + errors: tuple[str, ...] = () + + +def cloud_status(hresult: int) -> CloudState: + value = hresult & 0xFFFFFFFF + if value == 0: + return "SYNC" + # CfGetSyncRootInfoByPath-specific negative signals; other errors remain unknown. + if value in {0x80070186, 0x80070001}: # NOT_UNDER_SYNC_ROOT / this API's INVALID_FUNCTION + return "NOT_UNDER_SYNC_ROOT" + return "UNKNOWN" + + +def windows_drive_kind(root: str) -> DriveType: + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + query = kernel.GetDriveTypeW + query.argtypes, query.restype = [ctypes.c_wchar_p], ctypes.c_uint + kinds: dict[int, DriveType] = {2: "REMOVABLE", 3: "FIXED", 4: "REMOTE"} + return kinds.get(query(root), "UNKNOWN") + + +def windows_signals(ancestor: Path) -> tuple[DriveType, CloudState, bool, bool, tuple[str, ...]]: + errors: list[str] = [] + kind: DriveType = "UNKNOWN" + cloud: CloudState = "UNKNOWN" + attributes = 0 + try: + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + kind = windows_drive_kind(ancestor.anchor) + if kind == "REMOTE": + return kind, cloud, False, False, () + get_attributes = kernel.GetFileAttributesW + get_attributes.argtypes, get_attributes.restype = [ctypes.c_wchar_p], ctypes.c_uint32 + attributes = get_attributes(str(ancestor)) + if attributes == 0xFFFFFFFF: + errors.append("FILE_ATTRIBUTES_UNAVAILABLE") + attributes = 0 + except (AttributeError, OSError): + errors.append("DRIVE_PROBE_UNAVAILABLE") + try: + library = ctypes.WinDLL("cldapi", use_last_error=True) + query = library.CfGetSyncRootInfoByPath + query.argtypes = [ + ctypes.c_wchar_p, + ctypes.c_int, + ctypes.c_void_p, + ctypes.c_uint32, + ctypes.POINTER(ctypes.c_uint32), + ] + query.restype = ctypes.c_long + info = ctypes.c_longlong() + result = query(str(ancestor), 0, ctypes.byref(info), ctypes.sizeof(info), None) + cloud = cloud_status(result) + if cloud == "UNKNOWN": + errors.append(f"CLOUD_QUERY_FAILED_{result & 0xFFFFFFFF:08X}") + except (AttributeError, OSError): + errors.append("CLOUD_API_UNAVAILABLE") + return ( + kind, + cloud, + bool(attributes & (0x1000 | 0x80000 | 0x400000)), + bool(attributes & 0x400), + tuple(errors), + ) + + +def bounded_text(path: Path) -> str | None: + try: + with path.open("rb") as stream: + raw = stream.read(1_048_577) + except FileNotFoundError: + return None + if len(raw) > 1_048_576: + raise ValueError("LOCATION_METADATA_TOO_LARGE") + return raw.decode("utf-8") + + +def provider_roots( + environ: Mapping[str, str], reader: Callable[[Path], str | None] = bounded_text +) -> tuple[tuple[str, ...], tuple[str, ...]]: + roots = [ + value + for key, value in environ.items() + if key.casefold() in {"onedrive", "onedriveconsumer", "onedrivecommercial"} + and value.strip() + ] + errors: list[str] = [] + for variable in ("APPDATA", "LOCALAPPDATA"): + if not environ.get(variable): + continue + try: + text = reader(Path(environ[variable]) / "Dropbox" / "info.json") + data = None if text is None else json.loads(text) + if data is None: + continue + if not isinstance(data, dict): + raise ValueError("invalid Dropbox metadata") + for account in cast(dict[str, object], data).values(): + if isinstance(account, dict): + path = cast(dict[str, object], account).get("path") + if isinstance(path, str) and path: + roots.append(path) + except (OSError, ValueError, UnicodeError): + errors.append("SYNC_METADATA_UNAVAILABLE") + return tuple(dict.fromkeys(roots)), tuple(sorted(set(errors))) + + +def drivefs_candidates(environ: Mapping[str, str]) -> tuple[tuple[str, ...], tuple[str, ...]]: + if sys.platform != "win32": + return (), () + import winreg + + paths: list[str] = [] + errors: list[str] = [] + values = {k.casefold(): v for k, v in environ.items()} + for hive, key in ( + (winreg.HKEY_CURRENT_USER, r"Software\Google\DriveFS"), + (winreg.HKEY_LOCAL_MACHINE, r"Software\Google\DriveFS"), + (winreg.HKEY_LOCAL_MACHINE, r"Software\Policies\Google\DriveFS"), + ): + try: + with winreg.OpenKey(hive, key) as opened: + value, _kind = winreg.QueryValueEx(opened, "DefaultMountPoint") + if isinstance(value, str): + value = re.sub( + r"%([^%]+)%", lambda match: values.get(match[1].casefold(), match[0]), value + ) + if re.fullmatch(r"[A-Za-z]:?", value): + value = value[0] + ":\\" + if "%" in value: + errors.append("SYNC_ROOT_UNRESOLVED") + elif value: + paths.append(value) + except FileNotFoundError: + continue + except OSError: + errors.append("DRIVEFS_SETTINGS_UNAVAILABLE") + return tuple(dict.fromkeys(paths)), tuple(sorted(set(errors))) + + +def probe_workspace( + workspace: Path, + *, + platform: str | None = None, + environ: Mapping[str, str] | None = None, + resolve: Callable[[Path], Path] = lambda path: path.expanduser().resolve(), + reader: Callable[[Path], str | None] = bounded_text, + drive: Callable[[str], DriveType] = windows_drive_kind, + windows: Callable[ + [Path], tuple[DriveType, CloudState, bool, bool, tuple[str, ...]] + ] = windows_signals, + drivefs: Callable[ + [Mapping[str, str]], tuple[tuple[str, ...], tuple[str, ...]] + ] = drivefs_candidates, + registered: Callable[[], tuple[tuple[str, ...], tuple[str, ...]]] = registered_sync_roots, +) -> LocationSignals: + system, env = platform or sys.platform, os.environ if environ is None else environ + raw = str(workspace) + # Lexical network paths do not need a potentially blocking network resolve/stat. + if system == "win32" and windows_path_key(raw).startswith("\\\\"): + return LocationSignals(raw, system) + if system == "win32": + try: + anchor = PureWindowsPath(str(workspace.expanduser().absolute())).anchor + if anchor and drive(anchor) == "REMOTE": + return LocationSignals(str(workspace.absolute()), system, drive_type="REMOTE") + except (AttributeError, OSError): + pass + try: + canonical = resolve(workspace) + if system == "win32" and windows_path_key(str(canonical)).startswith("\\\\"): + return LocationSignals(str(canonical), system) + ancestor = canonical + while True: + try: + ancestor.stat() + break + except FileNotFoundError: + if ancestor == ancestor.parent: + raise OSError("no existing ancestor") from None + ancestor = ancestor.parent + except (OSError, RuntimeError, ValueError): + return LocationSignals(str(workspace.absolute()), system, errors=("PATH_PROBE_FAILED",)) + if system == "win32": + roots, metadata_errors = provider_roots(env, reader) + registered_roots, registry_errors = registered() + roots = (*roots, *registered_roots) + candidates, candidate_errors = drivefs(env) + normalized_roots: list[str] = [] + normalized_candidates: list[str] = [] + errors = [*metadata_errors, *candidate_errors, *registry_errors] + environment = {key.casefold(): value for key, value in env.items()} + for source, target in ((roots, normalized_roots), (candidates, normalized_candidates)): + for root in source: + try: + root = re.sub( + r"%([^%]+)%", + lambda match: environment.get(match[1].casefold(), match[0]), + root, + ) + if "%" in root: + raise ValueError("sync root environment is unresolved") + if not PureWindowsPath(root).is_absolute(): + raise ValueError("sync root is not absolute") + target.append(str(resolve(Path(root)))) + except (OSError, RuntimeError, ValueError): + errors.append("SYNC_ROOT_UNRESOLVED") + kind, cloud, attributes, reparse, failures = windows(ancestor) + return LocationSignals( + str(canonical), + system, + kind, + cloud, + attributes, + reparse, + tuple(normalized_roots), + tuple(normalized_candidates), + errors=tuple(sorted(set((*errors, *failures)))), + ) + if system.startswith("linux"): + try: + mounts = reader(Path("/proc/self/mountinfo")) or "" + return LocationSignals(str(canonical), system, mountinfo=mounts) + except (OSError, ValueError, UnicodeError): + return LocationSignals(str(canonical), system, errors=("MOUNTINFO_UNAVAILABLE",)) + return LocationSignals(str(canonical), system, errors=("PLATFORM_PROBE_UNAVAILABLE",)) diff --git a/src/thoth/adapters/storage/workspace_location_registry.py b/src/thoth/adapters/storage/workspace_location_registry.py new file mode 100644 index 0000000..59cc969 --- /dev/null +++ b/src/thoth/adapters/storage/workspace_location_registry.py @@ -0,0 +1,96 @@ +"""Read registered sync roots, without writing registry values or starting providers.""" + +from __future__ import annotations + +from typing import Literal, Protocol + +Hive = Literal["HKCU", "HKLM"] + + +class SyncRootRegistry(Protocol): + def subkeys(self, hive: Hive, path: str) -> tuple[str, ...]: ... + def value(self, hive: Hive, path: str, name: str) -> object: ... + def values(self, hive: Hive, path: str) -> tuple[object, ...]: ... + + +class WindowsSyncRootRegistry: + def subkeys(self, hive: Hive, path: str) -> tuple[str, ...]: + import winreg + + root = winreg.HKEY_CURRENT_USER if hive == "HKCU" else winreg.HKEY_LOCAL_MACHINE + found: list[str] = [] + with winreg.OpenKey(root, path, 0, winreg.KEY_READ | winreg.KEY_WOW64_64KEY) as key: + for index in range(1024): + try: + found.append(winreg.EnumKey(key, index)) + except OSError as error: + if error.winerror == 259: + return tuple(found) + raise + raise ValueError("sync root registry enumeration exceeded its bound") + + def value(self, hive: Hive, path: str, name: str) -> object: + import winreg + + root = winreg.HKEY_CURRENT_USER if hive == "HKCU" else winreg.HKEY_LOCAL_MACHINE + with winreg.OpenKey(root, path, 0, winreg.KEY_READ | winreg.KEY_WOW64_64KEY) as key: + value, _kind = winreg.QueryValueEx(key, name) + return value + + def values(self, hive: Hive, path: str) -> tuple[object, ...]: + import winreg + + root = winreg.HKEY_CURRENT_USER if hive == "HKCU" else winreg.HKEY_LOCAL_MACHINE + found: list[object] = [] + with winreg.OpenKey(root, path, 0, winreg.KEY_READ | winreg.KEY_WOW64_64KEY) as key: + for index in range(1024): + try: + _name, value, _kind = winreg.EnumValue(key, index) + found.append(value) + except OSError as error: + if error.winerror == 259: + return tuple(found) + raise + raise ValueError("sync root registry enumeration exceeded its bound") + + +def registered_sync_roots( + registry: SyncRootRegistry | None = None, +) -> tuple[tuple[str, ...], tuple[str, ...]]: + reader = registry or WindowsSyncRootRegistry() + roots: list[str] = [] + errors: list[str] = [] + locations: tuple[tuple[Hive, str, str], ...] = ( + ("HKCU", r"Software\Microsoft\OneDrive\Accounts", "onedrive"), + ( + "HKLM", + r"SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\SyncRootManager", + "registered", + ), + ) + for hive, base, layout in locations: + try: + children = reader.subkeys(hive, base) + except FileNotFoundError: + continue + except (OSError, ValueError, ImportError): + errors.append("SYNC_REGISTRY_UNAVAILABLE") + continue + for child in children: + try: + values = ( + (reader.value(hive, base + "\\" + child, "UserFolder"),) + if layout == "onedrive" + else reader.values(hive, base + "\\" + child + r"\UserSyncRoots") + ) + for path in values: + if isinstance(path, str) and path: + roots.append(path) + else: + errors.append("SYNC_REGISTRY_ROOT_INVALID") + except FileNotFoundError: + # The glob has no configured root at this entry; this is not a denied read. + continue + except (OSError, ValueError): + errors.append("SYNC_REGISTRY_UNAVAILABLE") + return tuple(dict.fromkeys(roots)), tuple(sorted(set(errors))) diff --git a/src/thoth/application/commands/workspace_setup.py b/src/thoth/application/commands/workspace_setup.py index 0be5ca7..f0780e8 100644 --- a/src/thoth/application/commands/workspace_setup.py +++ b/src/thoth/application/commands/workspace_setup.py @@ -1,5 +1,6 @@ import asyncio from collections.abc import Callable +from copy import deepcopy from pydantic import JsonValue @@ -25,12 +26,14 @@ def __init__( model_connected: Callable[[], bool], ready_projection: Callable[[WorkspaceSetupState], dict[str, JsonValue]] | None = None, workspace_id: str | None = None, + workspace_location: dict[str, JsonValue] | None = None, ) -> None: self._setup = setup self._mode = deployment_mode or parse_deployment_mode() self._model_connected = model_connected self._ready_projection = ready_projection self._workspace_id = workspace_id + self._workspace_location = deepcopy(workspace_location) def _setup_unavailable( self, error: WorkspaceSetupUnavailable, *, readiness: bool @@ -135,6 +138,8 @@ async def update(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]: async def ready(self, value: dict[str, JsonValue]) -> dict[str, JsonValue]: payload = await self._ready(value) + if self._workspace_location is not None: + payload["workspace_location"] = deepcopy(self._workspace_location) if self._mode is DeploymentMode.HOSTED_REVIEW and self._workspace_id is not None: payload["browser_scope_id"] = browser_submission_scope(self._workspace_id) return {**payload, "response_languages": ["ko", "en"], "response_language_scope": "REQUEST"} diff --git a/src/thoth/apps/research_entry_composition.py b/src/thoth/apps/research_entry_composition.py index 6abdb0f..5e0d0cf 100644 --- a/src/thoth/apps/research_entry_composition.py +++ b/src/thoth/apps/research_entry_composition.py @@ -68,6 +68,7 @@ from thoth.apps.judgment_records_composition import install_judgment_records from thoth.apps.test_runtime import TestComponents from thoth.apps.trace_composition import install_trace_origin, register_trace_methods +from thoth.apps.workspace_location_presentation import workspace_location_payload from thoth.apps.workspace_paths import workspace_id from thoth.domain.deployment_mode import DeploymentMode, parse_deployment_mode from thoth.ports.criterion_contract import CriterionContractStorePort @@ -261,6 +262,7 @@ def create_research_entry( model_connected=lambda: _configured_model_available(settings.catalog, local_credentials), ready_projection=ready_projection, workspace_id=workspace_id(workspace) if workspace else None, + workspace_location=workspace_location_payload(stores), ) registry.register("workspace/setup/read", workspace_setup.read) registry.register("workspace/setup/update", workspace_setup.update) diff --git a/src/thoth/apps/runtime.py b/src/thoth/apps/runtime.py index 0e52cda..cc65fec 100644 --- a/src/thoth/apps/runtime.py +++ b/src/thoth/apps/runtime.py @@ -218,7 +218,7 @@ def create_runtime( model_catalog = hosted_review_catalog() if model_resolver is None: model_resolver = hosted_openai_resolver() - stores = open_stores(workspace, storage_factory) + stores = open_stores(workspace, storage_factory, deployment_mode=mode) ledger = stores.ledger try: clock = SystemClock() diff --git a/src/thoth/apps/storage_composition.py b/src/thoth/apps/storage_composition.py index 23332bd..72b9d2e 100644 --- a/src/thoth/apps/storage_composition.py +++ b/src/thoth/apps/storage_composition.py @@ -11,13 +11,21 @@ FieldMeasurementService, ReceiptDagService, ) +from thoth.domain.deployment_mode import DeploymentMode +from thoth.domain.storage_context import storage_mode_scope from thoth.ports.runtime import ClockPort, IdGeneratorPort from thoth.ports.store_bundle import StoreBundlePort, StoreFactoryPort, StoreFactoryRegistryPort -def open_stores(workspace: Path, factory: StoreFactoryPort | None = None) -> StoreBundlePort: +def open_stores( + workspace: Path, + factory: StoreFactoryPort | None = None, + *, + deployment_mode: DeploymentMode | None = None, +) -> StoreBundlePort: selected = factory or default_store_factory_registry().resolve("sqlite", "1.0.0") - return selected.open(workspace) + with storage_mode_scope(deployment_mode): + return selected.open(workspace) def open_registered_stores( diff --git a/src/thoth/apps/workspace_guard.py b/src/thoth/apps/workspace_guard.py index aab6c83..255c757 100644 --- a/src/thoth/apps/workspace_guard.py +++ b/src/thoth/apps/workspace_guard.py @@ -2,28 +2,67 @@ from __future__ import annotations +import sys from collections.abc import Generator from contextlib import contextmanager from pathlib import Path import typer +from thoth.adapters.storage.workspace_location import ( + check_workspace_location, + confirm_workspace_location, +) from thoth.adapters.storage.workspace_owner import WorkspaceInUse, acquire_workspace_owner +from thoth.apps.workspace_location_presentation import location_messages +from thoth.domain.deployment_mode import DeploymentMode +from thoth.domain.storage_context import storage_deployment_mode +from thoth.domain.workspace_location import WorkspaceLocation, WorkspaceLocationError WORKSPACE_IN_USE_EXIT_CODE = 3 @contextmanager -def workspace_guard() -> Generator[None]: +def workspace_guard(workspace: Path | None = None) -> Generator[None]: """A workspace another THOTH has open is refused with one Korean and one English line.""" try: + if workspace is not None and storage_deployment_mode() is DeploymentMode.LOCAL: + _prepare_cli_location(workspace) yield + except WorkspaceLocationError as error: + ko, en = location_messages(error.location) + typer.echo(ko, err=True) + typer.echo(en, err=True) + raise typer.Exit(code=4 if error.location.blocked else 5) from error except WorkspaceInUse as error: typer.echo(error.message_ko, err=True) typer.echo(error.message_en, err=True) raise typer.Exit(code=WORKSPACE_IN_USE_EXIT_CODE) from error +def _interactive_confirmation() -> bool: + return sys.stdin.isatty() + + +def _warn_location(location: WorkspaceLocation) -> None: + if location.kind == "REMOVABLE" or (location.kind == "UNKNOWN" and location.confirmed): + ko, en = location_messages(location) + typer.echo(f"{ko} / {en}", err=True) + + +def _prepare_cli_location(workspace: Path) -> None: + try: + location = check_workspace_location(workspace) + except WorkspaceLocationError as error: + if error.location.blocked or not _interactive_confirmation(): + raise + ko, en = location_messages(error.location) + if not typer.confirm(f"{ko}\n{en}", default=False, err=True): + raise typer.Exit(code=5) from error + location = confirm_workspace_location(workspace, expected=error.location) + _warn_location(location) + + @contextmanager def owned_workspace(workspace: Path, *, command: str) -> Generator[None]: """Own the workspace for a long-running command and let go when it ends. @@ -31,6 +70,8 @@ def owned_workspace(workspace: Path, *, command: str) -> Generator[None]: A server opens its runtime on the first request, which is too late to refuse a second one. """ with workspace_guard(): + if storage_deployment_mode() is DeploymentMode.LOCAL: + _warn_location(check_workspace_location(workspace)) ownership = acquire_workspace_owner(workspace, command=command) try: yield diff --git a/src/thoth/apps/workspace_location_commands.py b/src/thoth/apps/workspace_location_commands.py new file mode 100644 index 0000000..9605bac --- /dev/null +++ b/src/thoth/apps/workspace_location_commands.py @@ -0,0 +1,47 @@ +"""Explicit location confirmation changes only the location confirmation file.""" + +from pathlib import Path +from typing import Annotated + +import typer + +from thoth.adapters.storage import workspace_location as location_policy +from thoth.adapters.storage.workspace_location import ( + check_workspace_location, + confirm_workspace_location, + forget_workspace_location, +) +from thoth.apps.workspace_guard import workspace_guard +from thoth.apps.workspace_location_presentation import location_detail, location_messages +from thoth.domain.workspace_location import WorkspaceLocationError + + +def workspace_location_info(workspace: Path) -> dict[str, object]: + try: + location = check_workspace_location(workspace) + except WorkspaceLocationError as error: + location = error.location + return dict(location_detail(location)) + + +def register_workspace_location_commands(app: typer.Typer, default_workspace: Path) -> None: + def accept_location( + workspace: Annotated[Path, typer.Option("--workspace")] = default_workspace, + forget: Annotated[ + bool, typer.Option("--forget", help="Remove only the saved location confirmation.") + ] = False, + ) -> None: + """Confirm an unverified workspace location after reviewing the warning.""" + with workspace_guard(): + if forget: + forget_workspace_location(workspace) + else: + before = location_policy.inspect_workspace_location(workspace) + if before.kind == "UNKNOWN": + ko, en = location_messages(before) + typer.echo(ko, err=True) + typer.echo(en, err=True) + location = confirm_workspace_location(workspace, expected=before) + typer.echo(f"{location.kind}: confirmed={str(location.confirmed).lower()}") + + app.command("accept-location")(accept_location) diff --git a/src/thoth/apps/workspace_location_presentation.py b/src/thoth/apps/workspace_location_presentation.py new file mode 100644 index 0000000..dcc0d5f --- /dev/null +++ b/src/thoth/apps/workspace_location_presentation.py @@ -0,0 +1,74 @@ +"""Approved bilingual location notices, projected from the decision used to open storage.""" + +from pydantic import JsonValue + +from thoth.apps.workspace_paths import default_workspace +from thoth.domain.workspace_location import WorkspaceLocation, WorkspaceLocationError +from thoth.ports.workspace_location import WorkspaceLocationReporterPort + +KINDS = { + "KNOWN_SYNC": ("동기화 폴더(OneDrive 등)", "a synced folder (such as OneDrive)"), + "REMOTE_SHARE": ("네트워크 폴더", "a network folder"), + "UNSUPPORTED_BRIDGE": ("Windows↔WSL 공유 경로", "a Windows↔WSL shared path"), +} + + +def location_messages(location: WorkspaceLocation) -> tuple[str, str]: + reason = ", ".join(location.reasons) + path = location.canonical_path + if location.blocked: + ko, en = KINDS[location.kind] + default = str(default_workspace()) + return ( + f"이 작업 공간 폴더는 {ko}에 있어 THOTH가 안전하게 쓸 수 없습니다 ({reason}). " + f"이 PC의 일반 폴더(예: {default})로 옮긴 뒤 다시 실행하세요. " + f"동기화가 필요하면 완료된 내보내기 파일만 동기화하세요. 작업 공간: {path}", + f"This workspace folder is on {en} ({reason}), where THOTH cannot work safely. " + f"Move it to a normal folder on this PC (for example {default}) and run again. " + f"If you need syncing, sync only finished exports. Workspace: {path}", + ) + if location.kind == "REMOVABLE": + return ( + "이동식 드라이브의 작업 공간입니다. " + "연결을 유지하고 사용을 마친 뒤 안전하게 제거하세요.", + "This workspace is on a removable drive. " + "Keep it connected and remove it safely when done.", + ) + if location.kind == "UNKNOWN": + ko = ( + f"이 폴더가 동기화·네트워크 폴더가 아닌지 확인하지 못했습니다 ({reason}). " + "THOTH는 이 위치의 안전을 보증하지 않습니다." + ) + en = ( + "THOTH could not confirm that this folder is not a synced or network folder " + f"({reason}). " + "It cannot vouch for it." + ) + if not location.confirmed: + ko += f' 계속하려면 확인하세요: thoth accept-location --workspace "{path}"' + en += f' To continue, confirm: thoth accept-location --workspace "{path}"' + return ko, en + return "", "" + + +def location_detail(location: WorkspaceLocation) -> dict[str, JsonValue]: + ko, en = location_messages(location) + return { + "classification": location.kind, + "reason_codes": list(location.reasons), + "confirmed": location.confirmed, + "message_ko": ko, + "message_en": en, + } + + +def location_error_detail(error: WorkspaceLocationError) -> dict[str, JsonValue]: + ko, en = location_messages(error.location) + return {"code": error.code, "message_ko": ko, "message_en": en} + + +def workspace_location_payload(stores: object) -> dict[str, JsonValue] | None: + if not isinstance(stores, WorkspaceLocationReporterPort): + return None + location = stores.workspace_location() + return None if location is None else location_detail(location) diff --git a/src/thoth/apps/workspace_paths.py b/src/thoth/apps/workspace_paths.py index 229095e..7be6e8c 100644 --- a/src/thoth/apps/workspace_paths.py +++ b/src/thoth/apps/workspace_paths.py @@ -8,6 +8,8 @@ from collections.abc import Mapping from pathlib import Path +from thoth.domain.workspace_identity import workspace_identity + def default_workspace( *, @@ -31,9 +33,15 @@ def selected_workspace(explicit: Path | None = None) -> Path: def workspace_id(root: Path, *, platform: str | None = None) -> str: normalized = str(root.expanduser().resolve()) - if (sys.platform if platform is None else platform) == "win32": - normalized = normalized.casefold() - return "workspace:" + hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:32] + return workspace_identity( + normalized, windows=(sys.platform if platform is None else platform) == "win32" + ) + + +def workspace_session_id(workspace: Path) -> str: + normalized = os.path.normcase(str(workspace.resolve())) + digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:24] + return f"tui:workspace:{digest}:local" def legacy_workspace_candidates(cwd: Path) -> tuple[Path, ...]: diff --git a/src/thoth/cli.py b/src/thoth/cli.py index cc096f3..c00c392 100644 --- a/src/thoth/cli.py +++ b/src/thoth/cli.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import hashlib import json import os import platform @@ -38,11 +37,16 @@ from thoth.apps.runtime import create_runtime from thoth.apps.tui_presentation import present_research_progress, render_tui_turn from thoth.apps.workspace_guard import owned_workspace, workspace_guard +from thoth.apps.workspace_location_commands import ( + register_workspace_location_commands, + workspace_location_info, +) from thoth.apps.workspace_paths import ( default_workspace, legacy_workspace_candidates, selected_workspace, workspace_id, + workspace_session_id, ) from thoth.domain.base import DomainModel from thoth.domain.enums import ModelRole @@ -61,12 +65,6 @@ class ModelProbeOutput(DomainModel): message: str -def workspace_session_id(workspace: Path) -> str: - normalized = os.path.normcase(str(workspace.resolve())) - digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:24] - return f"tui:workspace:{digest}:local" - - @app.callback(invoke_without_command=True) def main( context: typer.Context, @@ -87,7 +85,7 @@ def main( if context.invoked_subcommand is not None: return announce_workspace(workspace, command="thoth") - with workspace_guard(): + with workspace_guard(workspace): _interactive_shell( workspace, sandbox_profile=sandbox_profile, @@ -219,6 +217,7 @@ def workspace_info( { "workspace": str(root), "workspace_id": workspace_id(root), + "workspace_location": workspace_location_info(root), "selection": "EXPLICIT" if workspace is not None else "STABLE_DEFAULT", "legacy_candidates": [str(path) for path in legacy if path != root], "resume_hint": ( @@ -244,6 +243,7 @@ def claude_code_login( register_diagnostic_commands(app, console, DEFAULT_WORKSPACE) +register_workspace_location_commands(app, DEFAULT_WORKSPACE) @app.command("auth-status") @@ -341,7 +341,7 @@ def run_request( ] = None, ) -> None: """Execute one JSON-RPC request and emit one machine-readable JSON result.""" - with workspace_guard(): + with workspace_guard(workspace): runtime = create_runtime( workspace, sandbox_adapter=_sandbox_adapter(sandbox_profile, workspace), @@ -398,7 +398,7 @@ def rpc_stdio( ] = None, ) -> None: """Serve JSON-RPC over stdin/stdout JSONL until EOF.""" - with workspace_guard(): + with workspace_guard(workspace): runtime = create_runtime( workspace, sandbox_adapter=_sandbox_adapter(sandbox_profile, workspace), @@ -436,7 +436,7 @@ def events( "input": {"project_id": project_id, "operation_id": operation_id}, }, } - with workspace_guard(): + with workspace_guard(workspace): runtime = create_runtime(workspace) try: response = asyncio.run(handle_json_line(orjson.dumps(request), runtime.bus)) @@ -471,7 +471,7 @@ def run_pack( if selected_provider == "scripted" else create_codex_model(pack_workspace, model_name) ) - with workspace_guard(): + with workspace_guard(pack_workspace): result = asyncio.run( run_project_pack(loaded, workspace=pack_workspace, model=selected_model) ) diff --git a/src/thoth/domain/storage_context.py b/src/thoth/domain/storage_context.py new file mode 100644 index 0000000..1ef58a7 --- /dev/null +++ b/src/thoth/domain/storage_context.py @@ -0,0 +1,22 @@ +"""Pass the effective deployment mode through a synchronous storage-open scope.""" + +from collections.abc import Generator +from contextlib import contextmanager +from contextvars import ContextVar + +from thoth.domain.deployment_mode import DeploymentMode, parse_deployment_mode + +_mode: ContextVar[DeploymentMode | None] = ContextVar("storage_deployment_mode", default=None) + + +def storage_deployment_mode() -> DeploymentMode: + return _mode.get() or parse_deployment_mode() + + +@contextmanager +def storage_mode_scope(mode: DeploymentMode | None) -> Generator[None]: + token = _mode.set(mode or storage_deployment_mode()) + try: + yield + finally: + _mode.reset(token) diff --git a/src/thoth/domain/workspace_identity.py b/src/thoth/domain/workspace_identity.py new file mode 100644 index 0000000..8b1ad01 --- /dev/null +++ b/src/thoth/domain/workspace_identity.py @@ -0,0 +1,18 @@ +"""Pure identity of an already resolved workspace path.""" + +import ntpath +from hashlib import sha256 + + +def workspace_identity(path: str, *, windows: bool) -> str: + normalized = path.casefold() if windows else path + return "workspace:" + sha256(normalized.encode("utf-8")).hexdigest()[:32] + + +def windows_path_key(path: str) -> str: + value = path.replace("/", "\\") + if value.casefold().startswith("\\\\?\\unc\\"): + value = "\\\\" + value[8:] + elif value.startswith("\\\\?\\"): + value = value[4:] + return ntpath.normpath(value).casefold() diff --git a/src/thoth/domain/workspace_location.py b/src/thoth/domain/workspace_location.py new file mode 100644 index 0000000..b364d0f --- /dev/null +++ b/src/thoth/domain/workspace_location.py @@ -0,0 +1,50 @@ +"""A location assessment is a support-policy signal, never a storage-safety certificate.""" + +import hashlib +import json +from dataclasses import dataclass +from typing import Literal + +from pydantic import AwareDatetime + +from thoth.domain.base import DomainModel +from thoth.domain.ids import Sha256 + +LocationKind = Literal[ + "KNOWN_LOCAL", "KNOWN_SYNC", "REMOTE_SHARE", "UNSUPPORTED_BRIDGE", "REMOVABLE", "UNKNOWN" +] + + +class WorkspaceLocationConfirmation(DomainModel): + schema_version: Literal[1] = 1 + workspace_id: str + classification: Literal["UNKNOWN"] = "UNKNOWN" + reason_digest: Sha256 + confirmed_at: AwareDatetime + + +@dataclass(frozen=True) +class WorkspaceLocation: + kind: LocationKind + reasons: tuple[str, ...] + canonical_path: str + workspace_id: str + confirmed: bool = False + + @property + def blocked(self) -> bool: + return self.kind in {"KNOWN_SYNC", "REMOTE_SHARE", "UNSUPPORTED_BRIDGE"} + + @property + def reason_digest(self) -> str: + payload = json.dumps([self.kind, sorted(self.reasons)], separators=(",", ":")) + return hashlib.sha256(payload.encode()).hexdigest() + + +class WorkspaceLocationError(RuntimeError): + def __init__(self, location: WorkspaceLocation) -> None: + self.location = location + self.code = ( + "WORKSPACE_LOCATION_BLOCKED" if location.blocked else "WORKSPACE_LOCATION_UNCONFIRMED" + ) + super().__init__(self.code) diff --git a/src/thoth/ports/workspace_location.py b/src/thoth/ports/workspace_location.py new file mode 100644 index 0000000..5ce24ff --- /dev/null +++ b/src/thoth/ports/workspace_location.py @@ -0,0 +1,8 @@ +from typing import Protocol, runtime_checkable + +from thoth.domain.workspace_location import WorkspaceLocation + + +@runtime_checkable +class WorkspaceLocationReporterPort(Protocol): + def workspace_location(self) -> WorkspaceLocation | None: ... diff --git a/tests/cli_contract_capture.py b/tests/cli_contract_capture.py index 508c46c..775027f 100644 --- a/tests/cli_contract_capture.py +++ b/tests/cli_contract_capture.py @@ -18,6 +18,9 @@ ("profile-check", "--help"), ("source-stage", "--help"), ("git-snapshot", "--help"), + ("accept-location", "--help"), + ("accept-location", "--unknown-option"), + ("accept-location", "--workspace"), ("profile-check",), ("source-stage",), ("doctor", "--unknown-option"), diff --git a/tests/contract/test_staged_unconnected_source.py b/tests/contract/test_staged_unconnected_source.py new file mode 100644 index 0000000..2c8832d --- /dev/null +++ b/tests/contract/test_staged_unconnected_source.py @@ -0,0 +1,110 @@ +"""Staging bytes alone does not publish a source or make evidence available to research.""" + +import json +from io import BytesIO +from pathlib import Path + +from fastapi import Request, UploadFile +from pydantic import BaseModel +from tests.atomicity.harness import snapshot +from tests.integration.scoped_runtime import create_runtime +from tests.integration.storage_coverage_helpers import request, value + +from thoth.adapters.http.file_staging import stage_file_upload +from thoth.domain.model import ModelRequest, ModelResult +from thoth.ports.model import ModelPort + + +class NoModelCalls: + calls = 0 + + def resolve(self, *, provider: str, model: str | None) -> ModelPort: + return self + + async def structured[T: BaseModel](self, request: ModelRequest[T]) -> ModelResult[T]: + self.calls += 1 + raise AssertionError("staged-only evidence must not reach a model") + + +async def test_staged_bytes_remain_outside_sources_evidence_and_research(tmp_path: Path) -> None: + models = NoModelCalls() + workspace = tmp_path / "workspace" + runtime = create_runtime(workspace, model_resolver=models) + project, thread = "project:staged-only", "thread:staged-only" + upload = UploadFile(filename="staged.md", file=BytesIO(b"# Unconnected synthetic evidence\n")) + try: + value( + await runtime.bus.dispatch( + request( + "project/create", + "project", + { + "project_id": project, + "name": "Staged only", + "cutoff_at": "2026-10-01T00:00:00Z", + }, + ) + ) + ) + value( + await runtime.bus.dispatch( + request( + "thread/start", + "thread", + { + "project_id": project, + "thread_id": thread, + "problem": "Read connected evidence", + "scope": {}, + }, + ) + ) + ) + before = snapshot(runtime.ledger.engine) + staged = await stage_file_upload( + None, + Request({"type": "http", "headers": [(b"x-thoth-project-id", project.encode())]}), + upload, + _workspace_path_for_request=lambda _request: workspace, + ) + assert snapshot(runtime.ledger.engine) == before + path = workspace / "inbox" / str(staged["relative_path"]) + assert path.read_bytes() == b"# Unconnected synthetic evidence\n" + sources = value( + await runtime.bus.dispatch( + request("project/source/list", "sources", {"project_id": project}) + ) + ) + evidence = value( + await runtime.bus.dispatch( + request("evidence/list", "evidence", {"project_id": project}) + ) + ) + assert sources["artifacts"] == [] and sources["bindings"] == [] + assert evidence["spans"] == [] + research = await runtime.bus.dispatch( + request("thread/input", "research", {"project_id": project, "thread_id": thread}) + ) + assert research.error is not None + assert "requires at least one connected evidence span" in research.error.message + assert models.calls == 0 + out = Path(__file__).resolve().parents[2] / "outputs/u-upload-20261023" + out.mkdir(parents=True, exist_ok=True) + (out / "staged-unconnected-proof.json").write_text( + json.dumps( + { + "staged_file_exists": True, + "database_unchanged_by_staging": True, + "sources": sources, + "evidence": evidence, + "research_error": research.error.model_dump(mode="json"), + "model_calls": models.calls, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + finally: + await upload.close() + runtime.close() diff --git a/tests/contract/test_workspace_location_boundary.py b/tests/contract/test_workspace_location_boundary.py new file mode 100644 index 0000000..744f34b --- /dev/null +++ b/tests/contract/test_workspace_location_boundary.py @@ -0,0 +1,252 @@ +"""Injected support policy at real CLI/HTTP/store boundaries; no server or sync client starts.""" + +from __future__ import annotations + +import importlib +from dataclasses import replace +from pathlib import Path +from typing import Any + +import httpx +import pytest +from typer.testing import CliRunner + +from thoth import cli +from thoth.adapters.storage import bundle +from thoth.adapters.storage import workspace_location as policy +from thoth.adapters.storage.workspace_setup import FilesystemWorkspaceSetup +from thoth.application.commands.workspace_setup import WorkspaceSetupHandlers +from thoth.apps import workspace_guard +from thoth.apps.storage_composition import open_stores +from thoth.apps.workspace_location_presentation import location_detail, workspace_location_payload +from thoth.apps.workspace_paths import workspace_id +from thoth.domain.deployment_mode import DeploymentMode +from thoth.domain.storage_context import storage_deployment_mode, storage_mode_scope +from thoth.domain.workspace_location import LocationKind, WorkspaceLocation + +http_app = importlib.import_module("thoth.adapters.http.app") + + +@pytest.fixture +def location(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): + state: dict[str, Any] = {"kind": "UNKNOWN", "reasons": ("SYNTHETIC_LOCATION",), "calls": 0} + monkeypatch.setenv("THOTH_DEPLOYMENT_MODE", "LOCAL") + monkeypatch.chdir(tmp_path) + + def inspect(path: Path, **kwargs: object) -> WorkspaceLocation: + state["calls"] += 1 + return WorkspaceLocation( + state["kind"], state["reasons"], str(path.resolve()), workspace_id(path) + ) + + monkeypatch.setattr(policy, "inspect_workspace_location", inspect) + return state + + +@pytest.mark.parametrize("kind,code", [("KNOWN_SYNC", 4), ("UNKNOWN", 5)]) +@pytest.mark.parametrize( + "command", ["shell", "run", "rpc", "events", "run-pack", "serve", "desktop"] +) +def test_cli_refuses_before_database_lock_or_server( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + location: dict[str, Any], + kind: str, + code: int, + command: str, +) -> None: + location["kind"] = kind + monkeypatch.setattr(workspace_guard, "_interactive_confirmation", lambda: False) + root = tmp_path / "blocked" + args = [] if command == "shell" else [command] + if command == "events": + args += ["--project-id", "project:location", "--operation-id", "operation:location"] + if command == "run": + source = tmp_path / "request.json" + source.write_text("{}", encoding="utf-8") + args += ["--request", str(source)] + if command == "run-pack": + pack = tmp_path / "pack" + pack.mkdir() + + def fake_pack(*args: object, **kwargs: object) -> object: + return object() + + monkeypatch.setattr(cli, "load_project_pack", fake_pack) + args += ["--pack", str(pack)] + result = CliRunner().invoke(cli.app, [*args, "--workspace", str(root)]) + assert result.exit_code == code, result.output + assert location["calls"] >= 1 + assert "THOTH" in result.output + assert "작업 공간" in result.output if code == 4 else "확인하지 못했습니다" in result.output + assert ( + "cannot work safely" in result.output if code == 4 else "accept-location" in result.output + ) + assert not root.exists() + + +def test_confirm_forget_and_workspace_info_do_not_open_database( + tmp_path: Path, location: dict[str, Any] +) -> None: + root = tmp_path / "unknown" + runner = CliRunner() + preview = runner.invoke(cli.app, ["workspace", "--workspace", str(root)]) + assert preview.exit_code == 0 and "UNKNOWN" in preview.output and not root.exists() + accepted = runner.invoke(cli.app, ["accept-location", "--workspace", str(root)]) + assert accepted.exit_code == 0, accepted.output + assert policy.check_workspace_location(root).confirmed + assert list(root.iterdir()) == [root / policy.CONFIRMATION_FILE] + forgotten = runner.invoke(cli.app, ["accept-location", "--workspace", str(root), "--forget"]) + assert forgotten.exit_code == 0 and list(root.iterdir()) == [] + + +def test_tty_confirm_resumes_the_same_guarded_command( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, location: dict[str, Any] +) -> None: + root = tmp_path / "interactive" + monkeypatch.setattr(workspace_guard, "_interactive_confirmation", lambda: True) + seen: list[Path] = [] + + def fake_shell(workspace: Path, **kwargs: object) -> None: + seen.append(workspace) + + monkeypatch.setattr(cli, "_interactive_shell", fake_shell) + result = CliRunner().invoke(cli.app, ["--workspace", str(root)], input="y\n") + assert result.exit_code == 0, result.output + assert seen == [root] and policy.check_workspace_location(root).confirmed + assert not (root / "db").exists() + + +def test_blocked_confirmation_is_not_a_bypass(tmp_path: Path, location: dict[str, Any]) -> None: + root = tmp_path / "confirmed" + assert CliRunner().invoke(cli.app, ["accept-location", "--workspace", str(root)]).exit_code == 0 + before = (root / policy.CONFIRMATION_FILE).read_bytes() + location["kind"] = "KNOWN_SYNC" + result = CliRunner().invoke(cli.app, ["serve", "--workspace", str(root)]) + assert result.exit_code == 4 + assert (root / policy.CONFIRMATION_FILE).read_bytes() == before + assert not (root / "db").exists() + + +def test_tty_rejection_creates_no_confirmation_or_workspace( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, location: dict[str, Any] +) -> None: + monkeypatch.setattr(workspace_guard, "_interactive_confirmation", lambda: True) + root = tmp_path / "rejected" + result = CliRunner().invoke(cli.app, ["--workspace", str(root)], input="n\n") + assert result.exit_code == 5 and not root.exists() + + +def test_removable_warns_and_runs_without_confirmation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, location: dict[str, Any] +) -> None: + location["kind"] = "REMOVABLE" + root = tmp_path / "removable" + seen: list[Path] = [] + + def fake_shell(workspace: Path, **kwargs: object) -> None: + seen.append(workspace) + + monkeypatch.setattr(cli, "_interactive_shell", fake_shell) + result = CliRunner().invoke(cli.app, ["--workspace", str(root)]) + assert result.exit_code == 0 and seen == [root] + assert "이동식 드라이브" in result.output and "removable drive" in result.output + assert not root.exists() + + +def test_open_bundle_exposes_the_original_location_decision( + tmp_path: Path, location: dict[str, Any] +) -> None: + location["kind"] = "REMOVABLE" + stores = bundle.SqliteStoreFactory().open(tmp_path / "snapshot") + try: + initial = stores.workspace_location() + assert initial is not None and initial.kind == "REMOVABLE" + calls = location["calls"] + location["kind"] = "KNOWN_SYNC" + assert stores.workspace_location() == initial + assert workspace_location_payload(stores) == location_detail(initial) + assert location["calls"] == calls + finally: + stores.close() + + +@pytest.mark.parametrize( + "kind,code", + [("KNOWN_SYNC", "WORKSPACE_LOCATION_BLOCKED"), ("UNKNOWN", "WORKSPACE_LOCATION_UNCONFIRMED")], +) +async def test_lazy_http_returns_location_503_before_open( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, location: dict[str, Any], kind: str, code: str +) -> None: + location["kind"] = kind + root = tmp_path / "http" + monkeypatch.setenv("THOTH_WORKSPACE", str(root)) + app = http_app.create_app() + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://127.0.0.1:8765" + ) as client: + response = await client.post( + "/rpc/query", + json={ + "jsonrpc": "2.0", + "id": "ready", + "method": "workspace/ready", + "params": {"_meta": {"idempotencyKey": "ready"}, "input": {}}, + }, + ) + assert response.status_code == 503, response.text + detail = response.json()["detail"] + assert detail["code"] == code and detail["message_ko"] and detail["message_en"] + assert not root.exists() and app.state.runtime is None + + +def test_hosted_factory_bypasses_location_and_lock_and_resets_mode( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, location: dict[str, Any] +) -> None: + class StopAtMigration(Exception): + pass + + def migrate(path: Path) -> None: + raise StopAtMigration + + def no_lock(*args: object, **kwargs: object) -> None: + pytest.fail("hosted factory must not acquire the local lock") + + monkeypatch.setattr(bundle, "migrate_sqlite_database", migrate) + monkeypatch.setattr(bundle, "acquire_workspace_owner", no_lock) + with pytest.raises(StopAtMigration): + open_stores(tmp_path / "hosted", deployment_mode=DeploymentMode.HOSTED_REVIEW) + assert location["calls"] == 0 + assert storage_deployment_mode() is DeploymentMode.LOCAL + with storage_mode_scope(DeploymentMode.HOSTED_REVIEW): + assert storage_deployment_mode() is DeploymentMode.HOSTED_REVIEW + assert storage_deployment_mode() is DeploymentMode.LOCAL + + +@pytest.mark.parametrize("kind", ["REMOVABLE", "UNKNOWN"]) +async def test_ready_uses_the_open_decision_without_reprobing_or_mutable_aliases( + tmp_path: Path, kind: LocationKind +) -> None: + snapshot = WorkspaceLocation( + kind, ("SYNTHETIC",), str(tmp_path), workspace_id(tmp_path), kind == "UNKNOWN" + ) + + class Reporter: + def workspace_location(self) -> WorkspaceLocation: + return snapshot + + projection = workspace_location_payload(Reporter()) + assert projection is not None + handler = WorkspaceSetupHandlers( + FilesystemWorkspaceSetup(tmp_path), + model_connected=lambda: False, + workspace_id=snapshot.workspace_id, + workspace_location=projection, + ) + first = await handler.ready({}) + assert first["workspace_location"] == location_detail(snapshot) + projection["confirmed"] = not snapshot.confirmed + assert (await handler.ready({}))["workspace_location"] == location_detail(snapshot) + if kind == "UNKNOWN": + assert "accept-location" not in str(first["workspace_location"]) + assert replace(snapshot, confirmed=False).confirmed is False diff --git a/tests/fixtures/cli_registration_contract.json b/tests/fixtures/cli_registration_contract.json index 522dc24..b7df77d 100644 --- a/tests/fixtures/cli_registration_contract.json +++ b/tests/fixtures/cli_registration_contract.json @@ -3,7 +3,22 @@ "--help": { "exit_code": 0, "stderr": "", - "stdout": " \n Usage: thoth [OPTIONS] COMMAND [ARGS]... \n \n Open the CLI shell when no subcommand is supplied. \n \n┌─ Options ───────────────────────────────────────────────────────────────────────────────────────────────────────────┐\n│ --workspace PATH [default: contract-workspace] │\n│ --sandbox-profile TEXT disabled, docker-poc, gvisor, or managed-e2b [default: disabled] │\n│ --connector-config FILE │\n│ --help Show this message and exit. │\n└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘\n┌─ Commands ──────────────────────────────────────────────────────────────────────────────────────────────────────────┐\n│ version Report build and protocol versions. │\n│ workspace Show the selected data root and legacy paths without opening or moving them. │\n│ claude-code-login Open the official Claude Code sign-in for THOTH's isolated profile. │\n│ doctor Check local runtime capabilities without external calls. │\n│ connector-sandbox-doctor Inspect optional connector SDKs and sandbox runtimes without executing code. │\n│ profile-check Validate one deployment/model/connector boundary profile without activating it. │\n│ auth-status Report isolated Codex connectivity without exposing credentials. │\n│ auth-connect Keep the official login listener owned until completion or bounded timeout. │\n│ model-probe Use the normal workspace-bound model-only transport for one synthetic canary. │\n│ source-stage Copy one user-selected source into the bounded workspace inbox. │\n│ git-snapshot Create a read-only Git identity/status manifest in the workspace inbox. │\n│ run Execute one JSON-RPC request and emit one machine-readable JSON result. │\n│ rpc Serve JSON-RPC over stdin/stdout JSONL until EOF. │\n│ events Read one operation's hash-chained events and latest checkpoint. │\n│ run-pack Run one late-bound ProjectPack through ScriptedModel or Codex OAuth. │\n│ serve Run the local HTTP app server on loopback by default. │\n│ hosted-review-serve Bind the judge-facing review server. Local serve/desktop stay on loopback. │\n│ desktop Serve the existing web UI and local backend in one loopback window. │\n└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘\n\n" + "stdout": " \n Usage: thoth [OPTIONS] COMMAND [ARGS]... \n \n Open the CLI shell when no subcommand is supplied. \n \n┌─ Options ───────────────────────────────────────────────────────────────────────────────────────────────────────────┐\n│ --workspace PATH [default: contract-workspace] │\n│ --sandbox-profile TEXT disabled, docker-poc, gvisor, or managed-e2b [default: disabled] │\n│ --connector-config FILE │\n│ --help Show this message and exit. │\n└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘\n┌─ Commands ──────────────────────────────────────────────────────────────────────────────────────────────────────────┐\n│ version Report build and protocol versions. │\n│ workspace Show the selected data root and legacy paths without opening or moving them. │\n│ claude-code-login Open the official Claude Code sign-in for THOTH's isolated profile. │\n│ doctor Check local runtime capabilities without external calls. │\n│ connector-sandbox-doctor Inspect optional connector SDKs and sandbox runtimes without executing code. │\n│ profile-check Validate one deployment/model/connector boundary profile without activating it. │\n│ accept-location Confirm an unverified workspace location after reviewing the warning. │\n│ auth-status Report isolated Codex connectivity without exposing credentials. │\n│ auth-connect Keep the official login listener owned until completion or bounded timeout. │\n│ model-probe Use the normal workspace-bound model-only transport for one synthetic canary. │\n│ source-stage Copy one user-selected source into the bounded workspace inbox. │\n│ git-snapshot Create a read-only Git identity/status manifest in the workspace inbox. │\n│ run Execute one JSON-RPC request and emit one machine-readable JSON result. │\n│ rpc Serve JSON-RPC over stdin/stdout JSONL until EOF. │\n│ events Read one operation's hash-chained events and latest checkpoint. │\n│ run-pack Run one late-bound ProjectPack through ScriptedModel or Codex OAuth. │\n│ serve Run the local HTTP app server on loopback by default. │\n│ hosted-review-serve Bind the judge-facing review server. Local serve/desktop stay on loopback. │\n│ desktop Serve the existing web UI and local backend in one loopback window. │\n└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘\n\n" + }, + "accept-location --help": { + "exit_code": 0, + "stderr": "", + "stdout": " \n Usage: thoth accept-location [OPTIONS] \n \n Confirm an unverified workspace location after reviewing the warning. \n \n┌─ Options ───────────────────────────────────────────────────────────────────────────────────────────────────────────┐\n│ --workspace PATH [default: contract-workspace] │\n│ --forget Remove only the saved location confirmation. │\n│ --help Show this message and exit. │\n└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘\n\n" + }, + "accept-location --unknown-option": { + "exit_code": 2, + "stderr": "Usage: thoth accept-location [OPTIONS]\nTry 'thoth accept-location --help' for help.\n┌─ Error ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐\n│ No such option: --unknown-option │\n└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘\n", + "stdout": "" + }, + "accept-location --workspace": { + "exit_code": 2, + "stderr": "┌─ Error ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐\n│ Option '--workspace' requires an argument. │\n└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘\n", + "stdout": "" }, "connector-sandbox-doctor --help": { "exit_code": 0, @@ -58,6 +73,7 @@ "doctor", "connector-sandbox-doctor", "profile-check", + "accept-location", "auth-status", "auth-connect", "model-probe", diff --git a/tests/fixtures/workspace_location_cases.json b/tests/fixtures/workspace_location_cases.json new file mode 100644 index 0000000..2bdd4df --- /dev/null +++ b/tests/fixtures/workspace_location_cases.json @@ -0,0 +1,23 @@ +[ + {"name":"fixed-clean","path":"C:\\work\\project","platform":"win32","drive_type":"FIXED","cloud":"NOT_UNDER_SYNC_ROOT","expected":"KNOWN_LOCAL"}, + {"name":"cloud-root","path":"C:\\work\\project","platform":"win32","drive_type":"FIXED","cloud":"SYNC","expected":"KNOWN_SYNC"}, + {"name":"cloud-failed","path":"C:\\work\\project","platform":"win32","drive_type":"FIXED","cloud":"UNKNOWN","expected":"UNKNOWN"}, + {"name":"unc","path":"\\\\server\\share\\project","platform":"win32","drive_type":"UNKNOWN","cloud":"UNKNOWN","expected":"REMOTE_SHARE"}, + {"name":"wsl-dollar","path":"\\\\wsl$\\Ubuntu\\project","platform":"win32","drive_type":"REMOTE","cloud":"UNKNOWN","expected":"UNSUPPORTED_BRIDGE"}, + {"name":"wsl-localhost","path":"\\\\wsl.localhost\\Ubuntu\\project","platform":"win32","drive_type":"REMOTE","cloud":"UNKNOWN","expected":"UNSUPPORTED_BRIDGE"}, + {"name":"mapped-share","path":"Z:\\project","platform":"win32","drive_type":"REMOTE","cloud":"NOT_UNDER_SYNC_ROOT","expected":"REMOTE_SHARE"}, + {"name":"removable-clean","path":"E:\\project","platform":"win32","drive_type":"REMOVABLE","cloud":"NOT_UNDER_SYNC_ROOT","expected":"REMOVABLE"}, + {"name":"drive-unknown","path":"X:\\project","platform":"win32","drive_type":"UNKNOWN","cloud":"NOT_UNDER_SYNC_ROOT","expected":"UNKNOWN"}, + {"name":"root-casefold","path":"c:\\ONEDRIVE\\project","platform":"win32","drive_type":"FIXED","cloud":"NOT_UNDER_SYNC_ROOT","known_roots":["C:\\OneDrive"],"expected":"KNOWN_SYNC"}, + {"name":"root-component-boundary","path":"C:\\OneDrive-other\\project","platform":"win32","drive_type":"FIXED","cloud":"NOT_UNDER_SYNC_ROOT","known_roots":["C:\\OneDrive"],"expected":"KNOWN_LOCAL"}, + {"name":"name-alone","path":"C:\\work\\OneDrive\\project","platform":"win32","drive_type":"FIXED","cloud":"NOT_UNDER_SYNC_ROOT","expected":"KNOWN_LOCAL"}, + {"name":"cloud-attribute-uncertain","path":"C:\\work\\project","platform":"win32","drive_type":"FIXED","cloud":"NOT_UNDER_SYNC_ROOT","cloud_attributes":true,"expected":"UNKNOWN"}, + {"name":"ordinary-reparse","path":"C:\\work\\project","platform":"win32","drive_type":"FIXED","cloud":"NOT_UNDER_SYNC_ROOT","reparse":true,"expected":"KNOWN_LOCAL"}, + {"name":"linux-ext4","path":"/home/user/project","platform":"linux","mountinfo":"1 0 8:1 / / rw - ext4 /dev/sda rw","expected":"KNOWN_LOCAL"}, + {"name":"linux-wsl","path":"/mnt/c/project","platform":"linux","mountinfo":"1 0 8:1 / / rw - ext4 /dev/sda rw\n2 1 0:1 / /mnt/c rw - 9p C: rw","expected":"UNSUPPORTED_BRIDGE"}, + {"name":"linux-share","path":"/share/project","platform":"linux","mountinfo":"1 0 8:1 / / rw - ext4 /dev/sda rw\n2 1 0:1 / /share rw - cifs //server/share rw","expected":"REMOTE_SHARE"}, + {"name":"longest-mount-unknown","path":"/home/user/project","platform":"linux","mountinfo":"1 0 8:1 / / rw - ext4 /dev/sda rw\n2 1 0:1 / /home/user rw - fuse.custom custom rw","expected":"UNKNOWN"}, + {"name":"linux-prefix-boundary","path":"/mnt/case/project","platform":"linux","mountinfo":"1 0 8:1 / / rw - ext4 /dev/sda rw\n2 1 0:1 / /mnt/c rw - drvfs C: rw","expected":"KNOWN_LOCAL"}, + {"name":"missing-mountinfo","path":"/work/project","platform":"linux","mountinfo":"","expected":"UNKNOWN"}, + {"name":"unimplemented-platform","path":"/Users/user/project","platform":"darwin","expected":"UNKNOWN"} +] diff --git a/tests/integration/test_workspace_runtime_lock.py b/tests/integration/test_workspace_runtime_lock.py index babb1ed..1737943 100644 --- a/tests/integration/test_workspace_runtime_lock.py +++ b/tests/integration/test_workspace_runtime_lock.py @@ -42,7 +42,9 @@ workspace, marker = Path(sys.argv[1]), Path(sys.argv[2]) ownership = acquire_workspace_owner(workspace, command="thoth serve") -marker.write_text(str(os.getpid()), encoding="utf-8") +temporary_marker = marker.with_suffix(".tmp") +temporary_marker.write_text(str(os.getpid()), encoding="utf-8") +os.replace(temporary_marker, marker) sys.stdin.readline() ownership.release() """ diff --git a/tests/unit/test_workspace_location.py b/tests/unit/test_workspace_location.py new file mode 100644 index 0000000..7dc2481 --- /dev/null +++ b/tests/unit/test_workspace_location.py @@ -0,0 +1,319 @@ +"""Injected location contract; no real sync roots, registry, network mounts or servers.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +from dataclasses import replace +from pathlib import Path +from typing import Any + +import pytest + +from thoth.adapters.storage.workspace_location import ( + CONFIRMATION_FILE, + check_workspace_location, + classify_location, + confirm_workspace_location, + forget_workspace_location, +) +from thoth.adapters.storage.workspace_location_probes import ( + LocationSignals, + cloud_status, + probe_workspace, + provider_roots, +) +from thoth.adapters.storage.workspace_location_registry import Hive, registered_sync_roots +from thoth.apps.workspace_paths import workspace_id, workspace_session_id +from thoth.domain.deployment_mode import DeploymentMode +from thoth.domain.storage_context import storage_deployment_mode, storage_mode_scope +from thoth.domain.workspace_location import WorkspaceLocationError + +CASES: list[dict[str, Any]] = json.loads( + (Path(__file__).parents[1] / "fixtures/workspace_location_cases.json").read_text( + encoding="utf-8" + ) +) + + +@pytest.mark.parametrize("case", CASES, ids=lambda case: case["name"]) +def test_location_classification_contract(case: dict[str, Any]) -> None: + values = {key: value for key, value in case.items() if key not in {"name", "expected"}} + for key in ("known_roots", "candidate_roots", "errors"): + if key in values: + values[key] = tuple(values[key]) + location = classify_location(LocationSignals(**values)) + assert location.kind == case["expected"] + assert location.reasons and location.reason_digest + assert location.confirmed is False + + +def test_strong_signals_and_unknown_take_precedence_over_removable() -> None: + base = LocationSignals(r"E:\project", "win32", "REMOVABLE", "UNKNOWN") + assert classify_location(base).kind == "UNKNOWN" + assert classify_location(replace(base, cloud="NOT_UNDER_SYNC_ROOT")).kind == "REMOVABLE" + assert classify_location(replace(base, cloud="SYNC")).kind == "KNOWN_SYNC" + assert classify_location(replace(base, known_roots=("E:\\",))).kind == "KNOWN_SYNC" + + +def test_drivefs_configuration_is_only_a_candidate() -> None: + signal = LocationSignals( + r"G:\work", "win32", "FIXED", "NOT_UNDER_SYNC_ROOT", candidate_roots=("G:\\",) + ) + assert classify_location(signal).kind == "UNKNOWN" + assert classify_location(replace(signal, cloud="SYNC")).kind == "KNOWN_SYNC" + assert classify_location(replace(signal, drive_type="REMOTE")).kind == "REMOTE_SHARE" + + +@pytest.mark.parametrize("fs", ["nfs", "nfs4", "cifs", "smb3"]) +def test_network_mount_types(fs: str) -> None: + assert ( + classify_location( + LocationSignals("/work/x", "linux", mountinfo=f"1 0 0:1 / /work rw - {fs} x rw") + ).kind + == "REMOTE_SHARE" + ) + + +def test_mount_escaped_spaces_and_invalid_input() -> None: + signals = LocationSignals( + "/my workspace/x", "linux", mountinfo=r"1 0 0:1 / /my\040workspace rw - cifs x rw" + ) + assert classify_location(signals).kind == "REMOTE_SHARE" + assert classify_location(replace(signals, mountinfo="invalid")).kind == "UNKNOWN" + + +@pytest.mark.parametrize( + "status,expected", + [ + (0, "SYNC"), + (0x80070186, "NOT_UNDER_SYNC_ROOT"), + (0x80070001, "NOT_UNDER_SYNC_ROOT"), + (0x80070001 - 2**32, "NOT_UNDER_SYNC_ROOT"), + (0x80070178, "UNKNOWN"), + (0x80070195, "UNKNOWN"), + (0x80070005, "UNKNOWN"), + (0x80070490, "UNKNOWN"), + (0x80070002, "UNKNOWN"), + (1, "UNKNOWN"), + ], +) +def test_cloud_hresult_is_not_treated_as_local_on_generic_failure( + status: int, expected: str +) -> None: + assert cloud_status(status) == expected + + +def test_provider_metadata_is_injected_and_read_only() -> None: + reads: list[Path] = [] + + def read(path: Path) -> str: + reads.append(path) + return json.dumps({"personal": {"path": r"C:\Dropbox"}}) + + roots, errors = provider_roots({"OneDrive": r"C:\OneDrive", "APPDATA": r"C:\meta"}, read) + assert roots == (r"C:\OneDrive", r"C:\Dropbox") and not errors + assert len(reads) == 1 and reads[0].name == "info.json" + assert provider_roots({"APPDATA": "meta"}, lambda _: "invalid")[1] + + +class FakeRegistry: + def subkeys(self, hive: Hive, path: str) -> tuple[str, ...]: + return ("Personal", "Business1") if hive == "HKCU" else ("Provider",) + + def value(self, hive: Hive, path: str, name: str) -> object: + assert name == "UserFolder" + return r"C:\personal-sync" if path.endswith("Personal") else r"C:\business-sync" + + def values(self, hive: Hive, path: str) -> tuple[object, ...]: + assert path.endswith(r"\UserSyncRoots") + return (r"C:\registered-sync",) + + +def test_registry_roots_block_without_onedrive_environment() -> None: + roots, errors = registered_sync_roots(FakeRegistry()) + assert len(roots) == 3 and not errors + for root in roots: + decision = classify_location( + LocationSignals( + root + r"\project", "win32", "FIXED", "NOT_UNDER_SYNC_ROOT", known_roots=roots + ) + ) + assert decision.kind == "KNOWN_SYNC" + + +def test_registry_read_failure_never_becomes_local() -> None: + class Denied(FakeRegistry): + def subkeys(self, hive: Hive, path: str) -> tuple[str, ...]: + raise PermissionError("synthetic registry denial") + + roots, errors = registered_sync_roots(Denied()) + assert not roots and errors == ("SYNC_REGISTRY_UNAVAILABLE",) + assert ( + classify_location( + LocationSignals(r"C:\normal", "win32", "FIXED", "NOT_UNDER_SYNC_ROOT", errors=errors) + ).kind + == "UNKNOWN" + ) + + +def test_unconfigured_registry_entries_are_not_failed_reads() -> None: + class Unconfigured(FakeRegistry): + def value(self, hive: Hive, path: str, name: str) -> object: + raise FileNotFoundError("no UserFolder value") + + def values(self, hive: Hive, path: str) -> tuple[object, ...]: + raise FileNotFoundError("no UserSyncRoots subkey") + + assert registered_sync_roots(Unconfigured()) == ((), ()) + + +def unknown(root: Path, reason: str = "PROBE_UNAVAILABLE") -> LocationSignals: + return LocationSignals(str(root.resolve()), "win32", errors=(reason,)) + + +def test_confirmation_is_bound_to_path_kind_and_reason_and_forget_is_narrow(tmp_path: Path) -> None: + root = tmp_path / "unknown" + probe = unknown + with pytest.raises(WorkspaceLocationError) as first: + check_workspace_location(root, probe=probe) + assert first.value.code == "WORKSPACE_LOCATION_UNCONFIRMED" and not root.exists() + confirmed = confirm_workspace_location(root, probe=probe, expected=first.value.location) + original = (root / CONFIRMATION_FILE).read_bytes() + assert confirmed.confirmed and check_workspace_location(root, probe=probe).confirmed + assert (root / CONFIRMATION_FILE).read_bytes() == original + assert list(root.iterdir()) == [root / CONFIRMATION_FILE] + with pytest.raises(WorkspaceLocationError): + check_workspace_location(root, probe=lambda path: unknown(path, "CHANGED")) + moved = tmp_path / "moved" + moved.mkdir() + (moved / CONFIRMATION_FILE).write_bytes(original) + with pytest.raises(WorkspaceLocationError): + check_workspace_location(moved, probe=probe) + with pytest.raises(WorkspaceLocationError) as blocked: + check_workspace_location(root, probe=lambda path: replace(unknown(path), cloud="SYNC")) + assert blocked.value.code == "WORKSPACE_LOCATION_BLOCKED" + (root / "keep.txt").write_text("keep", encoding="utf-8") + forget_workspace_location(root) + assert not (root / CONFIRMATION_FILE).exists() and (root / "keep.txt").read_text() == "keep" + assert not (root / "db").exists() + + +def test_changed_confirmation_basis_is_not_silently_accepted(tmp_path: Path) -> None: + root = tmp_path / "changed" + expected = classify_location(unknown(root)) + with pytest.raises(WorkspaceLocationError): + confirm_workspace_location( + root, probe=lambda path: unknown(path, "NEW_REASON"), expected=expected + ) + assert not root.exists() + + +def test_blocked_location_cannot_be_confirmed_or_create_files(tmp_path: Path) -> None: + root = tmp_path / "blocked" + with pytest.raises(WorkspaceLocationError): + confirm_workspace_location(root, probe=lambda path: replace(unknown(path), cloud="SYNC")) + assert not root.exists() + + +def test_corrupt_confirmation_is_unconfirmed_without_overwrite(tmp_path: Path) -> None: + target = tmp_path / CONFIRMATION_FILE + target.write_bytes(b"bad JSON") + with pytest.raises(WorkspaceLocationError): + check_workspace_location(tmp_path, probe=unknown) + assert target.read_bytes() == b"bad JSON" + + +def test_shared_workspace_ids_keep_the_previous_algorithms(tmp_path: Path) -> None: + path = str(tmp_path.expanduser().resolve()) + for platform in ("win32", "linux"): + normalized = path.casefold() if platform == "win32" else path + assert ( + workspace_id(tmp_path, platform=platform) + == "workspace:" + hashlib.sha256(normalized.encode()).hexdigest()[:32] + ) + session = hashlib.sha256(os.path.normcase(path).encode()).hexdigest()[:24] + assert workspace_session_id(tmp_path) == f"tui:workspace:{session}:local" + + +def test_existing_ancestor_probe_does_not_create_future_workspace(tmp_path: Path) -> None: + target = tmp_path / "missing" / "child" + observed: list[Path] = [] + + def windows(path: Path): + observed.append(path) + return "FIXED", "NOT_UNDER_SYNC_ROOT", False, False, () + + signals = probe_workspace( + target, + platform="win32", + environ={}, + windows=windows, + drive=lambda _: "FIXED", + drivefs=lambda _: ((), ()), + registered=lambda: ((), ()), + ) + assert observed == [tmp_path] and not target.exists() + assert signals.path == str(target.resolve()) + + +async def test_mode_context_is_task_local_and_resets_on_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("THOTH_DEPLOYMENT_MODE", "LOCAL") + assert storage_deployment_mode() is DeploymentMode.LOCAL + + async def worker(mode: DeploymentMode) -> DeploymentMode: + with storage_mode_scope(mode): + await asyncio.sleep(0) + assert storage_deployment_mode() is mode + with pytest.raises(RuntimeError), storage_mode_scope(DeploymentMode.HOSTED_REVIEW): + raise RuntimeError("synthetic") + assert storage_deployment_mode() is mode + return storage_deployment_mode() + + assert await asyncio.gather( + worker(DeploymentMode.LOCAL), worker(DeploymentMode.HOSTED_REVIEW) + ) == [DeploymentMode.LOCAL, DeploymentMode.LOCAL] + assert storage_deployment_mode() is DeploymentMode.LOCAL + + +@pytest.mark.parametrize( + "target,expected", + [ + (r"\\server\share\workspace", "REMOTE_SHARE"), + (r"\\?\UNC\server\share\workspace", "REMOTE_SHARE"), + (r"\\wsl$\Ubuntu\workspace", "UNSUPPORTED_BRIDGE"), + (r"\\?\UNC\wsl.localhost\Ubuntu\workspace", "UNSUPPORTED_BRIDGE"), + ], +) +def test_network_aliases_are_classified_without_network_stat_or_resolve( + target: str, expected: str +) -> None: + def unexpected_resolve(path: Path) -> Path: + raise AssertionError("network resolution must not run") + + signals = probe_workspace(Path(target), platform="win32", resolve=unexpected_resolve) + assert classify_location(signals).kind == expected + + +def test_resolved_network_alias_is_classified_before_stat(tmp_path: Path) -> None: + signals = probe_workspace( + tmp_path / "alias", + platform="win32", + drive=lambda _: "FIXED", + resolve=lambda _: Path(r"\\server\share\workspace"), + ) + assert classify_location(signals).kind == "REMOTE_SHARE" + + +def test_resolution_failure_remains_unknown_without_creating_workspace(tmp_path: Path) -> None: + def denied(path: Path) -> Path: + raise PermissionError("synthetic resolution denial") + + target = tmp_path / "unreadable" + signals = probe_workspace(target, platform="win32", drive=lambda _: "FIXED", resolve=denied) + assert classify_location(signals).kind == "UNKNOWN" + assert signals.errors == ("PATH_PROBE_FAILED",) and not target.exists() diff --git a/tests/unit/test_workspace_location_entry.py b/tests/unit/test_workspace_location_entry.py new file mode 100644 index 0000000..1ee2607 --- /dev/null +++ b/tests/unit/test_workspace_location_entry.py @@ -0,0 +1,69 @@ +"""Location policy must run before existing lock/database entry points touch a workspace.""" + +import json +import os +from pathlib import Path + +import pytest + +from thoth.adapters.storage import bundle +from thoth.apps import workspace_guard + + +class LocationDenied(Exception): + pass + + +class LockReachedBeforePolicy(Exception): + pass + + +@pytest.mark.parametrize("entry", ["store", "server-owner"]) +def test_location_policy_precedes_workspace_lock( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, entry: str +) -> None: + workspace = tmp_path / "blocked" + trace: list[str] = [] + monkeypatch.setenv("THOTH_DEPLOYMENT_MODE", "LOCAL") + + def deny(*args: object, **kwargs: object) -> None: + trace.append("location-policy") + raise LocationDenied("synthetic blocked location") + + def stop_lock(*args: object, **kwargs: object) -> None: + trace.append("lock") + raise LockReachedBeforePolicy("lock reached before the location policy") + + target = bundle if entry == "store" else workspace_guard + monkeypatch.setattr(target, "check_workspace_location", deny, raising=False) + monkeypatch.setattr(target, "acquire_workspace_owner", stop_lock) + try: + with pytest.raises(LocationDenied): + if entry == "store": + bundle.SqliteStoreFactory().open(workspace) + else: + with workspace_guard.owned_workspace(workspace, command="thoth serve"): + pytest.fail("a blocked server reached its body") + assert trace == ["location-policy"] + finally: + out = ( + Path(__file__).resolve().parents[2] + / "outputs/w-location-20261023" + / os.environ.get("THOTH_W_PHASE", "direct") + ) + out.mkdir(parents=True, exist_ok=True) + (out / f"entry-{entry}.json").write_text( + json.dumps( + { + "entry": entry, + "trace": trace, + "workspace_created": workspace.exists(), + "database_created": (workspace / "db").exists(), + "server_started": False, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + assert not workspace.exists()