From 930eded49f171a5e7d63ab96e262483ee42013e1 Mon Sep 17 00:00:00 2001 From: veil-chow-fyaic <247294299+veil-chow-fyaic@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:09:03 +0800 Subject: [PATCH] feat: sync VS Code with DocFerry 0.0.72 --- .github/workflows/marketplace.yml | 2 +- CHANGELOG.md | 24 ++ DEVELOPMENT.md | 9 +- PRIVACY.md | 18 +- README.md | 14 +- SECURITY.md | 3 + docs/FEATURES.md | 8 +- ...ketplace-0.2.7-mainline-sync-2026-08-28.md | 62 ++++ package-lock.json | 4 +- package.json | 7 +- runtime/PROVENANCE.json | 10 +- runtime/src/docferry_agent_kit/__init__.py | 2 +- runtime/src/docferry_agent_kit/cli.py | 197 +++++++++++-- .../src/docferry_agent_kit/local_assets.py | 267 ++++++++++++++++++ .../docferry_agent_kit/workspace_identity.py | 124 ++++++++ scripts/sync_agent_kit.py | 5 +- scripts/verify_source.py | 6 +- scripts/verify_vsix.py | 4 +- src/cli.ts | 18 ++ src/contracts.ts | 46 ++- src/extension.ts | 57 +++- src/test/contracts.test.ts | 50 +++- src/test/suite/index.ts | 17 +- src/tree.ts | 17 +- 24 files changed, 880 insertions(+), 91 deletions(-) create mode 100644 docs/operations/evidence/marketplace-0.2.7-mainline-sync-2026-08-28.md create mode 100644 runtime/src/docferry_agent_kit/local_assets.py create mode 100644 runtime/src/docferry_agent_kit/workspace_identity.py diff --git a/.github/workflows/marketplace.yml b/.github/workflows/marketplace.yml index 28bd483..25f490d 100644 --- a/.github/workflows/marketplace.yml +++ b/.github/workflows/marketplace.yml @@ -6,7 +6,7 @@ on: release_tag: description: Checksummed GitHub release to verify before manual Marketplace upload required: true - default: v0.2.6 + default: v0.2.7 type: string permissions: diff --git a/CHANGELOG.md b/CHANGELOG.md index 352dcd6..0878b8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 0.2.7 - 2026-08-28 + +### Added + +- Publish supported workspace-local images, audio, video, and attachments + referenced by a shared Markdown file or folder. Hidden, unsupported, and + outside-workspace files remain private. +- Show a concise notice when referenced local files are omitted, with redacted + details available in the local DocFerry output channel. + +### Fixed + +- Accept the current DocFerry account-selection login contract while continuing + to reject untrusted origins, return paths, prompts, and query parameters. +- Use the same workspace identity and cross-surface share claim/update rules as + current DocFerry mainline, preventing duplicate ownership across VS Code, + Obsidian, CLI, and MCP. +- Hide the Folder Share creation action unless the signed-in service grants the + `docferry.publish.folder` capability. Existing folder history remains visible. + +### Changed + +- Updated the bundled and minimum Agent Kit version to `0.4.6`. + ## 0.2.6 - 2026-08-19 ### Fixed diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 656e31d..c3b339b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -17,10 +17,11 @@ npm run build:helper npm run package:vsix ``` -`sync:agent-kit` refreshes the three pinned CLI runtime files when this directory -is built inside the private DocFerry monorepo. In the public release repository, -it verifies and preserves the vendored runtime recorded in -`runtime/PROVENANCE.json`. +`sync:agent-kit` refreshes the five pinned CLI runtime files when this directory +is built inside the DocFerry monorepo. From a standalone public checkout, set +`DOCFERRY_MONOREPO_ROOT` to a reviewed DocFerry mainline checkout when advancing +the vendored runtime. With no source checkout configured, the command verifies +and preserves the runtime recorded in `runtime/PROVENANCE.json`. The generated VSIX is platform-specific because it contains a native helper and its private runtime directory under `bin/helper/`. Never copy that directory diff --git a/PRIVACY.md b/PRIVACY.md index 57fe7c6..56679fe 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy -Last updated: August 12, 2026. +Last updated: August 28, 2026. DocFerry for VS Code connects to the hosted DocFerry service at `docferry.bondie.io`. The extension does not contain advertising SDKs or sell @@ -12,10 +12,12 @@ personal information. the extension version, operating-system description, and a random local instance identifier. Authentication completes in the system browser. - **Share Markdown:** the selected Markdown, title, workspace-relative source - path, content hash, and publication settings are sent only after confirmation. + path, content hash, publication settings, and supported referenced local + images, audio, video, or attachments are sent only after confirmation. - **Share a folder:** visible Markdown in the selected folder, relative paths, - titles, hashes, and publication settings are sent only after confirmation. - Hidden files and paths outside the workspace are excluded. + titles, hashes, publication settings, and supported referenced local files are + sent only after confirmation. Hidden files, unsupported files, and paths + outside the workspace are excluded. - **Import a DocFerry share:** the selected share URL and optional password are sent to retrieve the document and assets. - **Detailed note:** the confirmed public source URL is sent to DocFerry's @@ -38,9 +40,11 @@ the extension can resume after reload. It does not store the source URL or generated Markdown there, and clears the record after save, cancellation, or a terminal failure. -The DocFerry output channel records operation names and redacted error details. -It does not intentionally log session tokens, imported content, shared Markdown, -share URLs, titles, or workspace paths. +The DocFerry output channel records operation names, redacted error details, and +local warnings when referenced files cannot be published. A warning may contain +a redacted local file name or path for troubleshooting, but never the file +content. The channel does not intentionally log session tokens, imported +content, shared Markdown, share URLs, or titles, and remains on the device. ## Controls diff --git a/README.md b/README.md index d0ae5b6..b713361 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,11 @@ DocFerry works with ordinary folders and repositories; Obsidian is optional. saves ordinary public URLs, and creates detailed notes for supported sources when the connected account has Pro access. - **Share Markdown** from the editor or Explorer after a clear confirmation. -- **Share a folder** as one navigable collection with Pro access. Hidden files - and paths outside the selected workspace stay excluded. + Supported workspace-local images, audio, video, and attachments referenced by + the note are included with it. +- **Share a folder** as one navigable collection with Pro access. Supported + local files referenced by its notes are included; hidden, unsupported, and + outside-workspace files stay private. - **Manage shares** in the Activity Bar: open, copy, update, stop, and remove stopped history records. - **Open DocFerry Dashboard** with a short-lived product handoff. Account and @@ -67,9 +70,10 @@ Imported notes are written to `DocFerry Imports` by default. Change ## Privacy and security DocFerry reads only content selected for an action. Publishing sends the chosen -Markdown or visible folder documents to `docferry.bondie.io`; Advanced Import -sends the confirmed public URL for hosted processing. The extension stores no -Auth0, Stripe, SynapseHub operator, or AI-provider credential. +Markdown or visible folder documents and supported referenced local files to +`docferry.bondie.io`; Advanced Import sends the confirmed public URL for hosted +processing. The extension stores no Auth0, Stripe, SynapseHub operator, or +AI-provider credential. Workspaces must be trusted and backed by a local filesystem. Commands use argument arrays with `shell: false`, paths are contained to the chosen workspace, diff --git a/SECURITY.md b/SECURITY.md index e0a889c..57dde94 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -18,6 +18,9 @@ way to follow up. Do not include active credentials or private user content. - The bundled helper is version-checked before use and starts with `shell: false`. - Workspace paths are canonicalized, bounded to an open workspace, and checked against symbolic-link escapes. +- Referenced local files are resolved only inside the selected workspace; + hidden and unsupported files are excluded, and references inside code or + comments are not treated as publishable assets. - Authentication uses a short-lived Device Code approval in the system browser. - Publishing, stopping links, and deleting stopped history require explicit confirmation. The server independently enforces ownership and state. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 4c453a6..697004e 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -11,13 +11,15 @@ separate entitlement model. | Import DocFerry share | Complete | Saves into the selected workspace | | Save ordinary public link | Complete | No remote source fetch | | Advanced Import | Complete | Pro capability, provider contract, confirmation | -| Share Markdown | Complete | Free/Pro server limits | -| Share visible Markdown folder | Complete | Pro capability and atomic revision | +| Share Markdown and local assets | Complete | Free/Pro server limits; workspace-contained supported files | +| Share visible Markdown folder and local assets | Complete | Server capability, atomic revision, workspace containment | | Open/copy/update/stop share | Complete | Note and folder variants | | Delete stopped history | Complete | Separate permanent confirmation | | Plan and usage | Complete | Notes, folders, and detailed-note monthly usage | | Full Obsidian theme capture | Not applicable | Requires Obsidian rendering context | | Agent conversation slash commands | Separate Agent Kit | CLI/MCP/Skill distribution, not VS Code Chat transcript access | -The extension uses returned limits and feature gates and cannot grant or mutate +Folder Share creation is shown only when the signed-in service grants +`docferry.publish.folder`; existing folder history remains visible. The +extension uses returned limits and feature gates and cannot grant or mutate server-managed access roles. diff --git a/docs/operations/evidence/marketplace-0.2.7-mainline-sync-2026-08-28.md b/docs/operations/evidence/marketplace-0.2.7-mainline-sync-2026-08-28.md new file mode 100644 index 0000000..d0b26b0 --- /dev/null +++ b/docs/operations/evidence/marketplace-0.2.7-mainline-sync-2026-08-28.md @@ -0,0 +1,62 @@ +# DocFerry VS Code 0.2.7 mainline sync evidence + +Date: 2026-08-28 + +## Reviewed sources + +- DocFerry mainline: `f78eae80a808f5f04e31d651a1db287b1e2d0a13` +- Public VS Code base: `5c0fa39a632dd4be219e5b1f51917007637ca6e0` +- Bundled Agent Kit: `0.4.6` +- Candidate extension: `0.2.7` + +The review covered DocFerry `0.0.68` through the `0.0.72` Community review +candidate, Agent Kit `0.4.5` workspace identity, Agent Kit `0.4.6` local asset +publication, the production Device Code login response, and the public +Marketplace extension. + +## Closed drift + +- The extension accepts the current product-owned account-selection login URL + and rejects alternate origins, prompts, return paths, and extra parameters. +- The bundled helper uses the current cross-surface workspace identity and + claim/update contract shared by Obsidian, CLI, and MCP. +- Note and folder shares publish supported workspace-local images, audio, + video, and attachments. Hidden, unsupported, missing, and outside-workspace + files remain private. +- Omitted local references produce a concise user notice and redacted local + troubleshooting output. +- Folder Share creation follows the authoritative + `docferry.publish.folder` capability. Existing history remains visible. +- Public privacy, security, feature, development, and Marketplace copy now + describe the same behavior as the package. + +Checkout contract and hosted Dashboard navigation changes remain owned by the +DocFerry web/server surface and require no separate VS Code implementation. +Obsidian theme capture remains inapplicable outside Obsidian. + +## Verification + +- TypeScript check and bundle: passed. +- Contract tests: `21/21` passed. +- Extension Host test: passed on VS Code `1.96.4`. +- Marketplace release verifier tests: `4/4` passed. +- Public source verifier: passed, `61` source files, Agent Kit `0.4.6`. +- Agent Kit tests: `116` passed plus `20` subtests. +- npm audit: zero vulnerabilities. +- Production Device Code start returned the expected + `https://docferry.bondie.io/v0/auth/login` account-selection URL and the + extension trust contract accepted it. +- Isolated VSIX install: passed; signed-out Activity Bar showed a visible + **Connect Bondie account** system-browser action and no Folder Share creation + action. +- macOS arm64 VSIX: `14,665,895` bytes. +- VSIX SHA-256: + `e9e9bed95aeceffd5fb7af675a1dcccc32e88fdb963f9830f2f0c3f6554b1913`. +- Bundled helper: `docferry 0.4.6`; production HTTPS health passed. + +## Distribution boundary + +The Visual Studio Marketplace still serves `bondie.docferry@0.2.4` at the time +of this review. Version `0.2.7` must pass public repository CI, be built for all +release targets, and be uploaded through the authenticated Bondie publisher +page before it can be described as the Marketplace version. diff --git a/package-lock.json b/package-lock.json index 9b2d6ca..e923bc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "docferry", - "version": "0.2.6", + "version": "0.2.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "docferry", - "version": "0.2.6", + "version": "0.2.7", "license": "SEE LICENSE IN LICENSE", "devDependencies": { "@types/node": "^20.14.0", diff --git a/package.json b/package.json index 5e98a8f..8a8215b 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "docferry", "displayName": "DocFerry", "description": "Save links, create detailed notes, and share Markdown files or folders from VS Code.", - "version": "0.2.6", + "version": "0.2.7", "publisher": "bondie", "author": { "name": "Bondie", @@ -90,7 +90,8 @@ { "command": "docferry.shareFolder", "title": "DocFerry: Share Folder", - "icon": "$(folder-opened)" + "icon": "$(folder-opened)", + "enablement": "docferry.folderShareEnabled && isWorkspaceTrusted" }, { "command": "docferry.showMembership", @@ -230,7 +231,7 @@ }, { "command": "docferry.shareFolder", - "when": "explorerResourceIsFolder && isWorkspaceTrusted", + "when": "explorerResourceIsFolder && isWorkspaceTrusted && docferry.folderShareEnabled", "group": "docferry@2" } ], diff --git a/runtime/PROVENANCE.json b/runtime/PROVENANCE.json index dda139e..40d32a1 100644 --- a/runtime/PROVENANCE.json +++ b/runtime/PROVENANCE.json @@ -2,10 +2,12 @@ "canonical_source": "agent-kit/src/docferry_agent_kit", "component": "docferry-agent-kit-cli-runtime", "files": { - "__init__.py": "6c111b140917e8e2f6f50a62f7a3c998c88141cbb896a5a012232c89702da52b", - "cli.py": "e746b8cd7783988c8da0b369ea9cebbb12d921ff470afba070624b78710feee5", - "conversation.py": "421e065a0fae11c0024a8089d0a6bebcc3bc9816664aa083719cc9c2889f27d7" + "__init__.py": "b8e0ef8b4e686a20cff354a4cb5a06e844703da28625cb64e08f4d3077cca5a8", + "cli.py": "302d14052f36d8129d6757f8dda3ebae61f9f0d1271b308fb260836f3993c940", + "conversation.py": "421e065a0fae11c0024a8089d0a6bebcc3bc9816664aa083719cc9c2889f27d7", + "local_assets.py": "5228332c524e02ca15aa90d810236828e95558b71360a175e658cea8644be639", + "workspace_identity.py": "9446f4e55e3d5017504a16ae23e8a7661fd254a217bb9645719d0c5725f6052b" }, "schema_version": 1, - "version": "0.4.4" + "version": "0.4.6" } diff --git a/runtime/src/docferry_agent_kit/__init__.py b/runtime/src/docferry_agent_kit/__init__.py index 780e65b..f3d3956 100644 --- a/runtime/src/docferry_agent_kit/__init__.py +++ b/runtime/src/docferry_agent_kit/__init__.py @@ -1,3 +1,3 @@ """DocFerry CLI and MCP Agent integration kit.""" -__version__ = "0.4.4" +__version__ = "0.4.6" diff --git a/runtime/src/docferry_agent_kit/cli.py b/runtime/src/docferry_agent_kit/cli.py index 2008702..d469736 100644 --- a/runtime/src/docferry_agent_kit/cli.py +++ b/runtime/src/docferry_agent_kit/cli.py @@ -20,6 +20,7 @@ from pathlib import Path from pathlib import PurePosixPath from threading import Event, Thread +from typing import Callable from urllib.error import HTTPError, URLError from urllib.parse import parse_qs, urlencode, urljoin, urlparse from urllib.request import HTTPCookieProcessor, ProxyHandler, Request, build_opener @@ -30,11 +31,19 @@ resolve_conversation_destination, save_conversation, ) +from .local_assets import prepare_local_assets +from .workspace_identity import ( + legacy_workspace_ids, + source_paths_match, + source_path_matches_workspace, + unresolved_workspace_path, + workspace_id, +) DEFAULT_SERVER_URL = "https://docferry.bondie.io" OFFICIAL_SANDBOX_SERVER_URL = "https://sandbox-docferry.bondie.io" PERSISTED_SERVER_URLS = frozenset({DEFAULT_SERVER_URL, OFFICIAL_SANDBOX_SERVER_URL}) -DOCFERRY_CLI_VERSION = "0.4.4" +DOCFERRY_CLI_VERSION = "0.4.6" DEVICE_LOGIN_MAX_TRANSIENT_FAILURES = 5 DEVICE_LOGIN_MAX_RETRY_SECONDS = 15 MANDATORY_ADVANCED_IMPORT_PROVIDERS = frozenset({"bilibili", "tiktok", "douyin"}) @@ -71,6 +80,14 @@ """ +def runtime_client_platform() -> str: + if sys.platform == "darwin": + return "darwin" + if sys.platform.startswith("win"): + return "win32" + return "linux" + + @dataclass class Response: status_code: int @@ -197,6 +214,28 @@ def post( ) -> Response: return self.request("POST", path, body=body, auth=auth, extra_headers=extra_headers) + def post_bytes( + self, + path: str, + *, + data: bytes, + auth: bool = False, + extra_headers: dict[str, str] | None = None, + ) -> Response: + headers = {"User-Agent": f"DocFerryCLI/{DOCFERRY_CLI_VERSION}"} + if auth: + if not self.token: + raise CliError("No DocFerry session. Run `docferry login` first.") + headers["Authorization"] = f"Bearer {self.token}" + if extra_headers: + headers.update(extra_headers) + request = Request(urljoin(f"{self.base_url}/", path.lstrip("/")), data=data, headers=headers, method="POST") + try: + response = self.opener.open(request, timeout=30) + return Response(response.status, response.read().decode("utf-8")) + except HTTPError as exc: + return Response(exc.code, exc.read().decode("utf-8")) + def put(self, path: str, *, body: dict[str, object], auth: bool = False) -> Response: return self.request("PUT", path, body=body, auth=auth) @@ -414,6 +453,7 @@ def main() -> int: revoke = subparsers.add_parser("revoke") revoke.add_argument("share_id") + revoke.add_argument("--confirm", action="store_true") import_url = subparsers.add_parser("import-url") import_url.add_argument("url") @@ -539,14 +579,16 @@ def main() -> int: elif args.command == "history": print_json(share_history_summary(client, args.limit)) elif args.command in {"publish", "share-file"}: - print_json(require_ok(client.post("/v0/shares", body=share_payload(args), auth=True), "publish").json()) + print_json(require_ok(client.post("/v0/shares", body=share_payload(client, args), auth=True), "publish").json()) elif args.command == "share-folder": print_json(publish_folder(client, args)) elif args.command == "update": share_id = normalized_share_id(args.share_id) + update_payload = share_payload(client, args, is_update=True) + add_expected_vault_id_for_update(client, share_id, update_payload, args.workspace) print_json( require_ok( - client.put(f"/v0/shares/{share_id}", body=share_payload(args, is_update=True), auth=True), + client.put(f"/v0/shares/{share_id}", body=update_payload, auth=True), "update", ).json() ) @@ -565,8 +607,7 @@ def main() -> int: share_id = normalized_share_id(args.share_id) print_json(require_ok(client.get(f"/v0/shares/{share_id}/links", auth=True), "links").json()) elif args.command == "revoke": - share_id = normalized_share_id(args.share_id) - print_json(require_ok(client.delete(f"/v0/shares/{share_id}", auth=True), "revoke").json()) + print_json(unshare_command(client, args)) elif args.command == "unshare": print_json(unshare_command(client, args)) elif args.command == "delete-history": @@ -698,7 +739,7 @@ def conversation_command(client: Client, args: argparse.Namespace) -> dict[str, expires_at=args.expires_at, ) response = require_ok( - client.post("/v0/shares", body=share_payload(share_args), auth=True), + client.post("/v0/shares", body=share_payload(client, share_args), auth=True), "share conversation", ).json() return { @@ -727,7 +768,7 @@ def share_command(client: Client, args: argparse.Namespace) -> dict[str, object] return require_ok( client.post( "/v0/shares", - body=share_payload(share_args), + body=share_payload(client, share_args), auth=True, ), "share file", @@ -907,7 +948,7 @@ def validated_dashboard_handoff_url(server_url: str, value: object) -> str: def list_share_summary(client: Client, limit: int) -> dict[str, object]: - bounded = max(1, min(limit, 100)) + bounded = max(1, min(limit, 200)) body = require_ok(client.get(f"/v0/shares?limit={bounded}", auth=True), "list shares").json() return { "shares": body.get("shares") if isinstance(body.get("shares"), list) else [], @@ -915,6 +956,74 @@ def list_share_summary(client: Client, limit: int) -> dict[str, object]: } +def add_expected_vault_id_for_update( + client: Client, + share_id: str, + payload: dict[str, object], + workspace: str | None, +) -> None: + root = cli_workspace_root(workspace) + local_vault_id = workspace_id(root) + row = require_ok(client.get(f"/v0/shares/{share_id}", auth=True), "locate share source vault").json() + remote_vault_id = row.get("vault_id") + if remote_vault_id == local_vault_id: + remote_source_path = row.get("source_path") + if not source_paths_match( + remote_source_path, + payload.get("source_path"), + case_insensitive=runtime_client_platform() == "win32", + ): + if not isinstance(remote_source_path, str) or not remote_source_path.strip(): + raise CliError("DocFerry did not return the current Share source path.") + payload["expected_source_path"] = remote_source_path + return + raw_root = unresolved_workspace_path(workspace) + if remote_vault_id is None: + relative_path = str(payload.get("source_path") or "") + if not source_path_matches_workspace(row.get("source_path"), root, relative_path, raw_root): + raise CliError("This historical Share does not belong to this workspace source note.") + payload["expected_source_path"] = row.get("source_path") + payload["expected_vault_id"] = None + return + if remote_vault_id in legacy_workspace_ids(root, raw_root): + payload["expected_vault_id"] = remote_vault_id + payload["expected_source_path"] = row.get("source_path") + return + raise CliError("This Share belongs to another workspace. Update it from that workspace instead.") + + +def add_expected_folder_vault_id_for_update( + client: Client, + folder_share_id: str, + payload: dict[str, object], + workspace: str | None, +) -> None: + root = cli_workspace_root(workspace) + local_vault_id = workspace_id(root) + row = require_ok( + client.get(f"/v0/folder-shares/{folder_share_id}", auth=True), + "locate folder share source vault", + ).json() + remote_vault_id = row.get("vault_id") + if remote_vault_id == local_vault_id: + return + raw_root = unresolved_workspace_path(workspace) + if remote_vault_id is None: + relative_path = str(payload.get("source_folder") or "") + if relative_path == root.name: + relative_path = "." + if not source_path_matches_workspace(row.get("source_folder"), root, relative_path, raw_root): + raise CliError("This historical Folder Share does not belong to this workspace folder.") + payload["expected_source_folder"] = row.get("source_folder") + payload["expected_vault_id"] = None + return + if remote_vault_id in legacy_workspace_ids(root, raw_root): + payload["expected_vault_id"] = remote_vault_id + payload["expected_source_folder"] = row.get("source_folder") + return + raise CliError("This Folder Share belongs to another workspace. Update it from that workspace instead.") + + def share_history_summary(client: Client, limit: int) -> dict[str, object]: note_history = list_share_summary(client, limit) membership = membership_summary(client) @@ -1034,20 +1143,24 @@ def publish_folder(client: Client, args: argparse.Namespace) -> dict[str, object "plugin_version": DOCFERRY_CLI_VERSION, "obsidian_version": "cli", "vault_name": root.name, + "platform": runtime_client_platform(), + } + draft_payload: dict[str, object] = { + "folder_share_id": existing_id, + "vault_id": workspace_id(root), + "source_folder": source_folder, + "title": (args.title or folder.name or root.name).strip(), + "expected_document_count": len(documents), + "theme_mode": "reader", + "css_asset_id": None, + "client": client_info, } + if existing_id: + add_expected_folder_vault_id_for_update(client, existing_id, draft_payload, args.workspace) draft = require_ok( client.post( "/v0/folder-shares/drafts", - body={ - "folder_share_id": existing_id, - "vault_id": workspace_id(root), - "source_folder": source_folder, - "title": (args.title or folder.name or root.name).strip(), - "expected_document_count": len(documents), - "theme_mode": "reader", - "css_asset_id": None, - "client": client_info, - }, + body=draft_payload, auth=True, ), "prepare folder revision", @@ -1057,6 +1170,9 @@ def publish_folder(client: Client, args: argparse.Namespace) -> dict[str, object raise CliError("DocFerry did not return a folder revision id.") for index, path in enumerate(documents): markdown = path.read_text(encoding="utf-8") + prepared_assets = prepare_local_assets(markdown, root, path.parent, make_asset_uploader(client)) + report_asset_warnings(prepared_assets.warnings) + markdown = prepared_assets.markdown relative = path.relative_to(folder).as_posix() route_key = hashlib.sha256(relative.casefold().encode()).hexdigest()[:20] require_ok( @@ -1070,7 +1186,7 @@ def publish_folder(client: Client, args: argparse.Namespace) -> dict[str, object "markdown": markdown, "html_snapshot": None, "css_asset_id": None, - "assets": [], + "assets": prepared_assets.assets, "navigation_order": index, }, auth=True, @@ -1173,11 +1289,43 @@ def save_media_note_result(body: dict[str, object], output_arg: Path, *, overwri } -def share_payload(args: argparse.Namespace, *, is_update: bool = False) -> dict[str, object]: - _root, file_path, relative_path = cli_workspace_markdown_path(args.workspace, args.file) +def make_asset_uploader(client: Client) -> Callable[[bytes, str, str], str]: + def upload(data: bytes, filename: str, content_type: str) -> str: + body = require_ok( + client.post_bytes( + "/v0/assets", + data=data, + auth=True, + extra_headers={ + "Content-Type": content_type, + "X-Share-Asset-Hash": f"sha256:{hashlib.sha256(data).hexdigest()}", + "X-Share-Asset-Filename": filename, + }, + ), + f"upload asset {filename}", + ).json() + asset_id = str(body.get("asset_id") or "") + if not asset_id: + raise CliError(f"DocFerry did not return an asset id for {filename}.") + return asset_id + + return upload + + +def report_asset_warnings(warnings: list[str]) -> None: + for warning in warnings: + print(f"warning: local reference {warning}", file=sys.stderr) + + +def share_payload(client: Client, args: argparse.Namespace, *, is_update: bool = False) -> dict[str, object]: + root, file_path, relative_path = cli_workspace_markdown_path(args.workspace, args.file) markdown = file_path.read_text(encoding="utf-8") + prepared_assets = prepare_local_assets(markdown, root, file_path.parent, make_asset_uploader(client)) + report_asset_warnings(prepared_assets.warnings) + markdown = prepared_assets.markdown title = args.title or title_from_markdown(markdown) or file_path.stem payload: dict[str, object] = { + "vault_id": workspace_id(root), "source_path": normalized_cli_source_path(args.source_path, fallback=relative_path), "source_hash": f"sha256:{hashlib.sha256(markdown.encode('utf-8')).hexdigest()}", "title": title, @@ -1185,12 +1333,13 @@ def share_payload(args: argparse.Namespace, *, is_update: bool = False) -> dict[ "html_snapshot": None, "theme_mode": "reader", "css_asset_id": None, - "assets": [], + "assets": prepared_assets.assets, "expires_at": args.expires_at, "client": { "plugin_id": "docferry-cli", "plugin_version": DOCFERRY_CLI_VERSION, "obsidian_version": "cli", + "platform": runtime_client_platform(), }, } if args.password: @@ -2242,10 +2391,6 @@ def cli_folder_documents(root: Path, value: str) -> tuple[Path, str, list[Path]] return candidate, source_folder, documents -def workspace_id(root: Path) -> str: - return f"workspace_{hashlib.sha256(str(root).encode()).hexdigest()[:24]}" - - def save_link_note(url: str, output_arg: Path, *, overwrite: bool) -> dict[str, object]: parsed = urlparse(url) provider = media_note_provider_for_url(url) diff --git a/runtime/src/docferry_agent_kit/local_assets.py b/runtime/src/docferry_agent_kit/local_assets.py new file mode 100644 index 0000000..17fe2f1 --- /dev/null +++ b/runtime/src/docferry_agent_kit/local_assets.py @@ -0,0 +1,267 @@ +"""Local asset extraction, upload and rewriting for CLI/MCP publishing. + +The Obsidian plugin uploads referenced local files (images, media, +attachments) and rewrites snapshot sources to ``docferry-asset://`` URLs. +CLI and MCP publishers send Markdown only, so the same contract is honored +here at the Markdown level: references are extracted in document order, +files inside the workspace are uploaded through ``POST /v0/assets``, and the +reference syntax is rewritten to ``docferry-asset://{asset_id}`` before the +payload reaches the server, which then renders them as public asset URLs. + +Extraction mirrors ``plugin/src/local-image-refs.ts``: wiki embeds, wiki +links, Markdown images/links, and raw HTML ``src``/``href`` attributes on +media and anchor elements. References inside fenced code blocks, inline code +or HTML comments are masked and never leave the workspace. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable, Protocol + +CONTENT_TYPES_BY_EXTENSION: dict[str, str] = { + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "gif": "image/gif", + "webp": "image/webp", + "avif": "image/avif", + "bmp": "image/bmp", + "mp4": "video/mp4", + "mov": "video/quicktime", + "webm": "video/webm", + "otf": "font/otf", + "ttf": "font/ttf", + "woff": "font/woff", + "woff2": "font/woff2", + "pdf": "application/pdf", + "txt": "text/plain", + "csv": "text/csv", + "json": "application/json", + "zip": "application/zip", + "doc": "application/msword", + "xls": "application/vnd.ms-excel", + "ppt": "application/vnd.ms-powerpoint", + "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "mp3": "audio/mpeg", + "m4a": "audio/mp4", + "ogg": "audio/ogg", + "wav": "audio/wav", +} + +IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp", "avif", "bmp"} +VIDEO_EXTENSIONS = {"mp4", "mov", "webm"} + +WIKI_EMBED_PATTERN = re.compile(r"!\[\[([^\]\n]+)\]\]") +WIKI_LINK_PATTERN = re.compile(r"(?]*\bsrc\s*=\s*(?:\"([^\"\n]+)\"|'([^'\n]+)'|[^\s>]+)", + flags=re.IGNORECASE, +) +HTML_ANCHOR_HREF_PATTERN = re.compile( + r"]*\bhref\s*=\s*(?:\"([^\"\n]+)\"|'([^'\n]+)'|[^\s>]+)", + flags=re.IGNORECASE, +) + + +class AssetUploader(Protocol): + def __call__(self, data: bytes, filename: str, content_type: str) -> str: + """Upload one asset and return its server asset_id.""" + ... + + +@dataclass +class _Reference: + family: str + start: int + end: int + path: str + alias: str | None = None + value_start: int = 0 + value_end: int = 0 + + +@dataclass +class PreparedAssets: + markdown: str + assets: list[dict[str, str]] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + +def asset_role_for_extension(extension: str) -> str: + normalized = extension.lower() + if normalized in IMAGE_EXTENSIONS: + return "image" + if normalized in VIDEO_EXTENSIONS: + return "video" + return "attachment" + + +def is_remote_reference(path: str) -> bool: + return bool(re.match(r"^(?:https?:)?//", path, flags=re.IGNORECASE) or re.match(r"^(?:data|blob):", path, flags=re.IGNORECASE)) + + +def _is_uploadable_path(path: str) -> bool: + if not path: + return False + if is_remote_reference(path): + return False + if path.startswith(("obsidian://", "#", "mailto:", "tel:")): + return False + return True + + +def _masked_regions(markdown: str) -> list[tuple[int, int]]: + regions: list[tuple[int, int]] = [] + offset = 0 + fence: str | None = None + fence_indent = "" + fence_start = 0 + for line in markdown.split("\n"): + opener = re.match(r"^([ \t]*)(`{3,}|~{3,})", line) + if fence is None and opener: + fence = opener.group(2)[0] * 3 + fence_indent = opener.group(1) + fence_start = offset + elif fence is not None and opener and opener.group(1) == fence_indent and opener.group(2).startswith(fence): + regions.append((fence_start, offset + len(line))) + fence = None + offset += len(line) + 1 + if fence is not None: + regions.append((fence_start, len(markdown))) + for match in re.finditer(r"`[^`\n]+`", markdown): + regions.append((match.start(), match.end())) + for match in re.finditer(r"", markdown): + regions.append((match.start(), match.end())) + return regions + + +def _collect_references(markdown: str) -> list[_Reference]: + regions = _masked_regions(markdown) + references: list[_Reference] = [] + + def masked(start: int) -> bool: + return any(begin <= start < end for begin, end in regions) + + def split_wiki_target(raw: str) -> tuple[str, str | None]: + parts = raw.split("|", 1) + return parts[0].strip(), parts[1].strip() if len(parts) > 1 else None + + for match in WIKI_EMBED_PATTERN.finditer(markdown): + path, alias = split_wiki_target(match.group(1)) + references.append(_Reference("wiki_embed", match.start(), match.end(), path, alias)) + for match in WIKI_LINK_PATTERN.finditer(markdown): + path, alias = split_wiki_target(match.group(1)) + references.append(_Reference("wiki_link", match.start(), match.end(), path, alias)) + for pattern, family in ( + (MARKDOWN_IMAGE_PATTERN, "markdown_image"), + (MARKDOWN_LINK_PATTERN, "markdown_link"), + ): + for match in pattern.finditer(markdown): + raw = match.group(1) + path = re.split(r"\s+[\"']", raw, maxsplit=1)[0].strip().strip("<>") + references.append( + _Reference(family, match.start(), match.end(), path, value_start=match.start(1), value_end=match.end(1)) + ) + for pattern, family in ( + (HTML_MEDIA_SRC_PATTERN, "html_src"), + (HTML_ANCHOR_HREF_PATTERN, "html_href"), + ): + for match in pattern.finditer(markdown): + # The value sits in group 1 (double-quoted), 2 (single-quoted) or + # 3 (unquoted); only the participating group has a valid span. + group_index = next(index for index in (1, 2, 3) if match.group(index) is not None) + raw = match.group(group_index) + references.append( + _Reference( + family, + match.start(), + match.end(), + raw.strip(), + value_start=match.start(group_index), + value_end=match.end(group_index), + ) + ) + + return sorted( + (reference for reference in references if not masked(reference.start) and _is_uploadable_path(reference.path)), + key=lambda reference: reference.start, + ) + + +def _resolve_reference(path: str, workspace_root: Path, note_dir: Path) -> Path | None: + cleaned = path.replace("\\", "/") + candidate_parts = [part for part in cleaned.split("/") if part not in ("", ".")] + if not candidate_parts or any(part == ".." or part.startswith(".") for part in candidate_parts): + return None + for base in (note_dir, workspace_root): + candidate = base.joinpath(*candidate_parts) + try: + resolved = candidate.resolve(strict=True) + except OSError: + continue + if resolved.is_file() and resolved.is_relative_to(workspace_root): + return resolved + return None + + +def prepare_local_assets( + markdown: str, + workspace_root: Path, + note_dir: Path, + upload: AssetUploader, +) -> PreparedAssets: + workspace_root = workspace_root.resolve() + references = _collect_references(markdown) + if not references: + return PreparedAssets(markdown=markdown) + + asset_ids_by_path: dict[Path, str] = {} + assets: list[dict[str, str]] = [] + warnings: list[str] = [] + replacements: list[tuple[int, int, str]] = [] + + for reference in references: + resolved = _resolve_reference(reference.path, workspace_root, note_dir) + if resolved is None: + warnings.append(f"not uploaded (missing or outside the workspace): {reference.path}") + continue + extension = resolved.suffix.lstrip(".").lower() + content_type = CONTENT_TYPES_BY_EXTENSION.get(extension) + if resolved.suffix.lower() == ".md" or content_type is None: + warnings.append(f"not uploaded (unsupported type .{extension or 'unknown'}): {reference.path}") + continue + if resolved not in asset_ids_by_path: + data = resolved.read_bytes() + asset_id = upload(data, resolved.name, content_type) + asset_ids_by_path[resolved] = asset_id + assets.append( + { + "asset_id": asset_id, + "role": asset_role_for_extension(extension), + "original_path": reference.path, + } + ) + asset_url = f"docferry-asset://{asset_ids_by_path[resolved]}" + replacements.append(_replacement(reference, asset_url)) + + for start, end, replacement in sorted(replacements, key=lambda item: item[0], reverse=True): + markdown = markdown[:start] + replacement + markdown[end:] + return PreparedAssets(markdown=markdown, assets=assets, warnings=warnings) + + +def _replacement(reference: _Reference, asset_url: str) -> tuple[int, int, str]: + # Wiki syntax must be converted: the server renders raw wiki embeds as + # "image unavailable" placeholders, so they become Markdown links toward + # the uploaded asset instead. + if reference.family in ("wiki_embed", "wiki_link"): + label = reference.alias or Path(reference.path).stem + prefix = "!" if reference.family == "wiki_embed" else "" + return reference.start, reference.end, f"{prefix}[{label}]({asset_url})" + return reference.value_start, reference.value_end, asset_url diff --git a/runtime/src/docferry_agent_kit/workspace_identity.py b/runtime/src/docferry_agent_kit/workspace_identity.py new file mode 100644 index 0000000..f6c73ff --- /dev/null +++ b/runtime/src/docferry_agent_kit/workspace_identity.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import hashlib +import os +import re +import unicodedata +from pathlib import Path + + +def canonical_workspace_path(root: Path) -> str: + value = root.expanduser().resolve().as_posix().rstrip("/") + value = unicodedata.normalize("NFC", value) + # Match the Obsidian/Electron implementation. JavaScript lowercasing is + # not Unicode casefolding (for example, German sharp-s), so casefold() + # would create a different vault id for the same Windows workspace. + return value.lower() if os.name == "nt" else value + + +def workspace_id(root: Path) -> str: + """Return the stable vault identity used by the Obsidian plugin.""" + path = canonical_workspace_path(root) + name = unicodedata.normalize("NFC", path.rsplit("/", 1)[-1]) + source = f"{name}|{path}" + return f"vlt_{hashlib.sha256(source.encode('utf-8')).hexdigest()[:24]}" + + +def unresolved_workspace_path(value: str | None) -> Path: + raw = Path(value).expanduser() if value else Path(os.environ.get("PWD") or os.getcwd()).expanduser() + return Path(os.path.abspath(raw)) + + +def legacy_workspace_ids(root: Path, raw_root: Path | None = None) -> tuple[str, ...]: + """Return prior Agent Kit and Obsidian ids for explicit migration only.""" + resolved = root.expanduser().resolve() + unresolved = (raw_root or root).expanduser() + if not unresolved.is_absolute(): + unresolved = Path(os.path.abspath(unresolved)) + + candidates: list[tuple[str, str]] = [] + path_values = { + str(unresolved), + unresolved.as_posix(), + str(resolved), + resolved.as_posix(), + } + for path_value in path_values: + candidates.append(("workspace", path_value)) + for name in {unresolved.name, resolved.name}: + for path_value in path_values: + candidates.append(("vlt", f"{name}|{path_value}")) + + values: list[str] = [] + for prefix, source in candidates: + value = f"{prefix}_{hashlib.sha256(source.encode('utf-8')).hexdigest()[:24]}" + if value != workspace_id(resolved) and value not in values: + values.append(value) + return tuple(values) + + +def workspace_source_aliases( + root: Path, + relative_path: str, + raw_root: Path | None = None, +) -> tuple[str, ...]: + """Return exact physical source paths that can prove workspace ownership.""" + relative = Path(relative_path.replace("\\", "/")) + if relative.is_absolute() or ".." in relative.parts: + return () + resolved_root = root.expanduser().resolve() + unresolved_root = (raw_root or root).expanduser() + if not unresolved_root.is_absolute(): + unresolved_root = Path(os.path.abspath(unresolved_root)) + candidates = [ + unresolved_root / relative, + resolved_root / relative, + ] + try: + resolved_source = (resolved_root / relative).resolve(strict=True) + resolved_source.relative_to(resolved_root) + candidates.append(resolved_source) + except (OSError, ValueError): + pass + aliases: list[str] = [] + for candidate in candidates: + value = unicodedata.normalize("NFC", candidate.as_posix()) + if value not in aliases: + aliases.append(value) + return tuple(aliases) + + +def source_path_matches_workspace( + source_path: object, + root: Path, + relative_path: str, + raw_root: Path | None = None, +) -> bool: + if not isinstance(source_path, str) or not source_path.strip(): + return False + candidate = unicodedata.normalize("NFC", source_path.replace("\\", "/")) + aliases = workspace_source_aliases(root, relative_path, raw_root) + if os.name == "nt": + candidate = candidate.lower() + return any(candidate == alias.lower() for alias in aliases) + return candidate in aliases + + +def source_paths_match(candidate: object, target: object, *, case_insensitive: bool = False) -> bool: + """Match Share source paths with the same normalization as the service.""" + if not isinstance(candidate, str) or not isinstance(target, str): + return False + + def normalize(value: str) -> str: + path = "/".join(part for part in value.replace("\\", "/").strip().strip("/").split("/") if part) + return unicodedata.normalize("NFC", path) + + def is_windows_path(value: str) -> bool: + stripped = value.strip() + return stripped.startswith(("\\\\", "//")) or re.match(r"^[A-Za-z]:[\\/]", stripped) is not None + + left = normalize(candidate) + right = normalize(target) + if case_insensitive or is_windows_path(candidate) or is_windows_path(target): + return left.casefold() == right.casefold() + return left == right diff --git a/scripts/sync_agent_kit.py b/scripts/sync_agent_kit.py index 9dc29f2..ceda384 100644 --- a/scripts/sync_agent_kit.py +++ b/scripts/sync_agent_kit.py @@ -3,15 +3,16 @@ import hashlib import json +import os import shutil from pathlib import Path EXTENSION_ROOT = Path(__file__).resolve().parents[1] -REPO_ROOT = EXTENSION_ROOT.parent +REPO_ROOT = Path(os.environ.get("DOCFERRY_MONOREPO_ROOT", EXTENSION_ROOT.parent)).expanduser().resolve() SOURCE = REPO_ROOT / "agent-kit" / "src" / "docferry_agent_kit" TARGET = EXTENSION_ROOT / "runtime" / "src" / "docferry_agent_kit" -FILES = ("__init__.py", "cli.py", "conversation.py") +FILES = ("__init__.py", "cli.py", "conversation.py", "local_assets.py", "workspace_identity.py") def sha256(path: Path) -> str: diff --git a/scripts/verify_source.py b/scripts/verify_source.py index 8ee2541..7469fac 100644 --- a/scripts/verify_source.py +++ b/scripts/verify_source.py @@ -32,8 +32,8 @@ def source_files() -> list[Path]: def verify_runtime() -> None: provenance = json.loads((ROOT / "runtime" / "PROVENANCE.json").read_text(encoding="utf-8")) - if provenance.get("version") != "0.4.4": - raise SystemExit("Bundled Agent Kit provenance must remain at 0.4.4.") + if provenance.get("version") != "0.4.6": + raise SystemExit("Bundled Agent Kit provenance must remain at 0.4.6.") runtime = ROOT / "runtime" / "src" / "docferry_agent_kit" for name, expected in provenance.get("files", {}).items(): actual = hashlib.sha256((runtime / name).read_bytes()).hexdigest() @@ -67,7 +67,7 @@ def main() -> int: verify_runtime() verify_workflows() - print(json.dumps({"ok": True, "source_files": len(source_files()), "agent_kit": "0.4.4"})) + print(json.dumps({"ok": True, "source_files": len(source_files()), "agent_kit": "0.4.6"})) return 0 diff --git a/scripts/verify_vsix.py b/scripts/verify_vsix.py index 8e88629..de513a2 100644 --- a/scripts/verify_vsix.py +++ b/scripts/verify_vsix.py @@ -106,7 +106,7 @@ def main() -> int: text=True, timeout=90, ).stdout.strip() - if output != "docferry 0.4.4": + if output != "docferry 0.4.6": raise SystemExit(f"Unexpected bundled helper version: {output}") health = subprocess.run( [str(binary), "health"], @@ -127,7 +127,7 @@ def main() -> int: "vsix": VSIX.name, "sha256": digest, "size_bytes": vsix_size, - "helper": "0.4.4", + "helper": "0.4.6", "https_health": True, } ) diff --git a/src/cli.ts b/src/cli.ts index b0d7804..a70f272 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,7 @@ import { buildCliArgs, defaultCliCandidates, isSupportedAgentKitVersion, + localAssetWarningCount, MIN_AGENT_KIT_VERSION, parseJsonOutput, redactOutput @@ -29,6 +30,7 @@ export interface CliRunOptions { token?: vscode.CancellationToken; timeoutSeconds?: number; onStdout?: (chunk: string) => void; + notifyAssetWarnings?: boolean; } export interface CliRunResult { @@ -151,6 +153,22 @@ export class DocFerryCli { return; } if (code === 0) { + const safeStderr = redactOutput(stderr.trim()).slice(0, 8_000); + if (safeStderr) { + this.output.appendLine(safeStderr); + } + const assetWarningCount = localAssetWarningCount(stderr); + if (options.notifyAssetWarnings && assetWarningCount > 0) { + const noun = assetWarningCount === 1 ? "file" : "files"; + void vscode.window.showWarningMessage( + `DocFerry published the share without ${assetWarningCount} referenced local ${noun}.`, + "Show details" + ).then((action) => { + if (action === "Show details") { + this.output.show(true); + } + }); + } this.output.appendLine("Completed."); finish(undefined, { stdout, stderr }); return; diff --git a/src/contracts.ts b/src/contracts.ts index 361ed4b..176dadd 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; -export const MIN_AGENT_KIT_VERSION = "0.4.4"; +export const MIN_AGENT_KIT_VERSION = "0.4.6"; export const VS_CODE_LOGIN_START_ARGS = [ "login", "--device-code-start", @@ -70,12 +70,12 @@ export function folderShareConfirmation( if (relativePath === ".") { return { message: `Share the entire “${workspaceName}” workspace?`, - detail: "This publishes every visible Markdown file in the workspace and its subfolders. Hidden and non-Markdown files stay private." + detail: "This publishes visible Markdown plus supported local images, audio, video, and attachments referenced by those notes. Hidden, unsupported, and outside-workspace files stay private." }; } return { message: `Share Markdown files in “${path.basename(relativePath)}”?`, - detail: `Selected folder: ${relativePath}. Visible Markdown files in this folder and its subfolders will be published.` + detail: `Selected folder: ${relativePath}. Visible Markdown and supported referenced local files in this folder and its subfolders will be published.` }; } @@ -268,22 +268,52 @@ export function isTrustedDeviceLoginUrl(value: string | undefined): value is str } try { const url = new URL(value); - const keys = [...url.searchParams.keys()]; + const keys = [...url.searchParams.keys()].sort(); + if ( + keys.length !== 3 + || keys[0] !== "instance_type" + || keys[1] !== "prompt" + || keys[2] !== "return_path" + || url.searchParams.get("instance_type") !== "web_dashboard" + || url.searchParams.get("prompt") !== "select_account" + ) { + return false; + } + const returnPath = url.searchParams.get("return_path"); + if (!returnPath?.startsWith("/")) { + return false; + } + const activation = new URL(returnPath, url.origin); + const activationKeys = [...activation.searchParams.keys()]; return url.protocol === "https:" && url.hostname === "docferry.bondie.io" && url.port === "" && url.username === "" && url.password === "" - && url.pathname === "/activate" + && url.pathname === "/v0/auth/login" && url.hash === "" - && keys.length === 1 - && keys[0] === "user_code" - && /^[A-Z0-9-]{4,32}$/i.test(url.searchParams.get("user_code") || ""); + && activation.origin === url.origin + && activation.pathname === "/activate" + && activation.hash === "" + && activationKeys.length === 1 + && activationKeys[0] === "user_code" + && /^[A-Z0-9-]{4,32}$/i.test(activation.searchParams.get("user_code") || ""); } catch { return false; } } +export function canCreateFolderShare(membership: MembershipSummary): boolean { + return membership.feature_gates?.["docferry.publish.folder"] === true; +} + +export function localAssetWarningCount(stderr: string): number { + return stderr + .split(/\r?\n/) + .filter((line) => /^warning: local reference\b/i.test(line.trim())) + .length; +} + export function parseJsonOutput(stdout: string): T { const value = stdout.trim(); if (!value) { diff --git a/src/extension.ts b/src/extension.ts index 0f101fd..9fd82f8 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,6 +8,7 @@ import { advancedImportDecision, accountContextPath, AuthStatusSummary, + canCreateFolderShare, classifyOperationError, dashboardCommandArgs, DashboardLinkResult, @@ -76,8 +77,16 @@ export function activate(context: vscode.ExtensionContext): void { accountState = state; tree.setAccountState(state); void vscode.commands.executeCommand("setContext", "docferry.accountState", state); + if (state !== "signedIn") { + setFolderShareEnabled(false); + } updateStatus(); }; + const setFolderShareEnabled = (enabled: boolean) => { + tree.setFolderShareEnabled(enabled); + void vscode.commands.executeCommand("setContext", "docferry.folderShareEnabled", enabled); + }; + setFolderShareEnabled(false); updateStatus(); status.show(); @@ -94,7 +103,7 @@ export function activate(context: vscode.ExtensionContext): void { }), vscode.commands.registerCommand( "docferry.refresh", - () => refreshAccountState(cli, setAccountState) + () => refreshAccountState(cli, setAccountState, setFolderShareEnabled) ), vscode.commands.registerCommand( "docferry.openDashboard", @@ -103,13 +112,16 @@ export function activate(context: vscode.ExtensionContext): void { vscode.commands.registerCommand("docferry.openUrl", (url: string) => openExternal(url)), vscode.commands.registerCommand( "docferry.signIn", - () => signIn(cli, tree, setAccountState, detailedNotes) + () => signIn(cli, tree, setAccountState, setFolderShareEnabled, detailedNotes) ), vscode.commands.registerCommand( "docferry.signOut", () => signOut(cli, tree, detailedNotes, setAccountState) ), - vscode.commands.registerCommand("docferry.showMembership", () => showMembership(cli)), + vscode.commands.registerCommand( + "docferry.showMembership", + () => showMembership(cli, setFolderShareEnabled) + ), vscode.commands.registerCommand("docferry.shareCurrentFile", (uri?: vscode.Uri) => shareMarkdown(cli, tree, uri)), vscode.commands.registerCommand("docferry.shareFolder", (uri?: vscode.Uri) => shareFolder(cli, tree, uri)), vscode.commands.registerCommand("docferry.saveLink", () => saveLink(cli, tree, detailedNotes)), @@ -136,7 +148,7 @@ export function activate(context: vscode.ExtensionContext): void { ) ); void detailedNotes.resume(); - void refreshAccountState(cli, setAccountState); + void refreshAccountState(cli, setAccountState, setFolderShareEnabled); } export function deactivate(): void {} @@ -145,6 +157,7 @@ async function signIn( cli: DocFerryCli, tree: DocFerryTreeProvider, setAccountState: (state: AccountState) => void, + setFolderShareEnabled: (enabled: boolean) => void, detailedNotes?: DetailedNoteManager ): Promise { if (detailedNotes && !(await detailedNotes.guardAccountChange())) { @@ -198,7 +211,7 @@ async function signIn( if (!result) { return; } - setAccountState("signedIn"); + await refreshAccountState(cli, setAccountState, setFolderShareEnabled); tree.refresh(); const action = await vscode.window.showInformationMessage( "DocFerry is connected to your Bondie account.", @@ -259,7 +272,10 @@ async function signOut( void vscode.window.showInformationMessage("Signed out of DocFerry."); } -async function showMembership(cli: DocFerryCli): Promise { +async function showMembership( + cli: DocFerryCli, + setFolderShareEnabled: (enabled: boolean) => void +): Promise { const workspacePath = currentAccountContextPath(); const membership = await runWithProgress( "Checking DocFerry plan and usage", @@ -272,6 +288,7 @@ async function showMembership(cli: DocFerryCli): Promise { if (!membership) { return; } + setFolderShareEnabled(canCreateFolderShare(membership)); const action = await vscode.window.showInformationMessage(membershipLabel(membership), "Open dashboard"); if (action === "Open dashboard") { await openDashboard(cli, "membership"); @@ -296,7 +313,10 @@ async function shareMarkdown( const relative = workspaceRelativePath(workspace.uri.fsPath, uri.fsPath); const approved = await vscode.window.showWarningMessage( `Create a DocFerry link for “${path.basename(uri.fsPath)}”?`, - { modal: true }, + { + modal: true, + detail: "Supported local images, audio, video, and attachments referenced by this note will be included. Hidden, unsupported, and outside-workspace files stay private." + }, "Share" ); if (approved !== "Share") { @@ -307,6 +327,7 @@ async function shareMarkdown( (token) => cli.runJson(workspace.uri.fsPath, ["share", relative, "--confirm"], { label: "Share Markdown", token, + notifyAssetWarnings: true, timeoutSeconds: 120 }) ); @@ -359,6 +380,7 @@ async function shareFolder( (token) => cli.runJson(workspace.uri.fsPath, ["share", relative, "--confirm"], { label: "Share folder", token, + notifyAssetWarnings: true, timeoutSeconds: 300 }) ); @@ -601,6 +623,7 @@ async function updateShare( (token) => cli.runJson(workspacePath, ["update", node.shareId, sourcePath, "--password-mode", "keep"], { label: "Update note share", token, + notifyAssetWarnings: true, timeoutSeconds: 120 }) ); @@ -648,6 +671,7 @@ async function updateFolderShare( ], { label: "Update folder share", token, + notifyAssetWarnings: true, timeoutSeconds: 300 }) ); @@ -756,7 +780,8 @@ function currentAccountContextPath(): string { async function refreshAccountState( cli: DocFerryCli, - setAccountState: (state: AccountState) => void + setAccountState: (state: AccountState) => void, + setFolderShareEnabled: (enabled: boolean) => void ): Promise { setAccountState("checking"); try { @@ -764,7 +789,21 @@ async function refreshAccountState( label: "Check account", timeoutSeconds: 30 }); - setAccountState(status.authenticated ? "signedIn" : "signedOut"); + if (!status.authenticated) { + setAccountState("signedOut"); + return; + } + setAccountState("signedIn"); + try { + const membership = await cli.runJson( + currentAccountContextPath(), + ["membership"], + { label: "Refresh feature access", timeoutSeconds: 60 } + ); + setFolderShareEnabled(canCreateFolderShare(membership)); + } catch { + setFolderShareEnabled(false); + } } catch (error) { setAccountState(isAuthenticationError(error) ? "signedOut" : "error"); } diff --git a/src/test/contracts.test.ts b/src/test/contracts.test.ts index 3ac25bc..766262d 100644 --- a/src/test/contracts.test.ts +++ b/src/test/contracts.test.ts @@ -8,6 +8,7 @@ import { accountContextPath, advancedImportDecision, buildCliArgs, + canCreateFolderShare, classifyOperationError, dashboardCommandArgs, detailedNoteIndicatorCopy, @@ -20,6 +21,7 @@ import { isSupportedAgentKitVersion, isTrustedDashboardHandoffUrl, isTrustedDeviceLoginUrl, + localAssetWarningCount, mediaNoteProviderForUrl, mediaNoteFailureMessage, mediaNotePreview, @@ -127,9 +129,9 @@ test("workspace matching resolves aliases and chooses the most specific root", ( }); test("Agent Kit version contract requires the current Marketplace runtime", () => { - assert.equal(isSupportedAgentKitVersion("docferry 0.4.4\n"), true); + assert.equal(isSupportedAgentKitVersion("docferry 0.4.6\n"), true); assert.equal(isSupportedAgentKitVersion("docferry 0.5.0\n"), true); - assert.equal(isSupportedAgentKitVersion("docferry 0.4.3\n"), false); + assert.equal(isSupportedAgentKitVersion("docferry 0.4.5\n"), false); assert.equal(isSupportedAgentKitVersion("unexpected output\n"), false); }); @@ -167,12 +169,27 @@ test("VS Code sign-in uses its product-owned system-browser Device Code flow", ( "--no-browser" ]); assert.equal( - isTrustedDeviceLoginUrl("https://docferry.bondie.io/activate?user_code=BOND-1234"), + isTrustedDeviceLoginUrl( + "https://docferry.bondie.io/v0/auth/login?instance_type=web_dashboard&prompt=select_account&return_path=%2Factivate%3Fuser_code%3DBOND-1234" + ), true ); - assert.equal(isTrustedDeviceLoginUrl("https://account.bondie.io/activate?user_code=BOND-1234"), false); assert.equal( - isTrustedDeviceLoginUrl("https://docferry.bondie.io/activate?user_code=BOND-1234&next=bad"), + isTrustedDeviceLoginUrl( + "https://account.bondie.io/v0/auth/login?instance_type=web_dashboard&prompt=select_account&return_path=%2Factivate%3Fuser_code%3DBOND-1234" + ), + false + ); + assert.equal( + isTrustedDeviceLoginUrl( + "https://docferry.bondie.io/v0/auth/login?instance_type=web_dashboard&prompt=select_account&return_path=https%3A%2F%2Fevil.example%2Factivate%3Fuser_code%3DBOND-1234" + ), + false + ); + assert.equal( + isTrustedDeviceLoginUrl( + "https://docferry.bondie.io/v0/auth/login?instance_type=web_dashboard&prompt=login&return_path=%2Factivate%3Fuser_code%3DBOND-1234" + ), false ); }); @@ -180,14 +197,33 @@ test("VS Code sign-in uses its product-owned system-browser Device Code flow", ( test("folder sharing makes whole-workspace scope explicit", () => { assert.deepEqual(folderShareConfirmation("Project", "."), { message: "Share the entire “Project” workspace?", - detail: "This publishes every visible Markdown file in the workspace and its subfolders. Hidden and non-Markdown files stay private." + detail: "This publishes visible Markdown plus supported local images, audio, video, and attachments referenced by those notes. Hidden, unsupported, and outside-workspace files stay private." }); assert.deepEqual(folderShareConfirmation("Project", "notes/research"), { message: "Share Markdown files in “research”?", - detail: "Selected folder: notes/research. Visible Markdown files in this folder and its subfolders will be published." + detail: "Selected folder: notes/research. Visible Markdown and supported referenced local files in this folder and its subfolders will be published." }); }); +test("Folder Share creation follows the server capability instead of plan names", () => { + assert.equal(canCreateFolderShare({}), false); + assert.equal(canCreateFolderShare({ plan_key: "pro_monthly" }), false); + assert.equal(canCreateFolderShare({ access_role: "admin" }), false); + assert.equal(canCreateFolderShare({ feature_gates: { "docferry.publish.folder": true } }), true); +}); + +test("local asset warning parsing ignores unrelated helper output", () => { + assert.equal(localAssetWarningCount(""), 0); + assert.equal(localAssetWarningCount("notice: retrying\n"), 0); + assert.equal( + localAssetWarningCount( + "warning: local reference unsupported format: diagram.svg\n" + + "warning: local reference outside workspace: ../secret.png\n" + ), + 2 + ); +}); + test("import folder must stay visible and relative", () => { assert.equal(validateImportFolder("./DocFerry Imports"), "DocFerry Imports"); assert.throws(() => validateImportFolder("../private"), /visible/); diff --git a/src/test/suite/index.ts b/src/test/suite/index.ts index c211dd9..098db9a 100644 --- a/src/test/suite/index.ts +++ b/src/test/suite/index.ts @@ -29,7 +29,7 @@ export async function run(): Promise { assert.ok(extension, "DocFerry extension was not discovered by Extension Host"); await extension.activate(); assert.equal(extension.isActive, true); - assert.equal(extension.packageJSON.version, "0.2.6"); + assert.equal(extension.packageJSON.version, "0.2.7"); assert.ok( extension.packageJSON.activationEvents.includes("onStartupFinished"), "DocFerry must expose account state without requiring users to find its view first" @@ -42,6 +42,21 @@ export async function run(): Promise { ), "Signed-out users must get a native sign-in entry in the DocFerry view" ); + assert.equal( + extension.packageJSON.contributes.commands.find( + (entry: { command?: string }) => entry.command === "docferry.shareFolder" + )?.enablement, + "docferry.folderShareEnabled && isWorkspaceTrusted", + "Folder Share creation must follow the server capability context" + ); + assert.ok( + extension.packageJSON.contributes.menus["explorer/context"].some( + (entry: { command?: string; when?: string }) => + entry.command === "docferry.shareFolder" + && entry.when?.includes("docferry.folderShareEnabled") + ), + "Explorer Folder Share must follow the server capability context" + ); assert.equal(extension.packageJSON.pricing, "Free"); assert.equal(extension.packageJSON.preview, true); assert.equal(extension.packageJSON.capabilities.untrustedWorkspaces.supported, false); diff --git a/src/tree.ts b/src/tree.ts index 1b714b2..7d9e96b 100644 --- a/src/tree.ts +++ b/src/tree.ts @@ -70,14 +70,14 @@ const START_ACTIONS: ActionNode[] = [ { kind: "action", label: "Share current Markdown", - description: "Create a DocFerry link", + description: "Include supported local files", icon: "cloud-upload", command: "docferry.shareCurrentFile" }, { kind: "action", label: "Share a folder", - description: "Publish its Markdown files", + description: "Publish notes and local files", icon: "folder-opened", command: "docferry.shareFolder" } @@ -125,6 +125,7 @@ export class DocFerryTreeProvider implements vscode.TreeDataProvider, private readonly detailedNoteSubscription?: vscode.Disposable; private readonly delayedRefreshes = new Set(); private accountState: AccountState = "checking"; + private folderShareEnabled = false; readonly onDidChangeTreeData = this.changeEmitter.event; constructor( @@ -155,6 +156,14 @@ export class DocFerryTreeProvider implements vscode.TreeDataProvider, this.refresh(); } + setFolderShareEnabled(enabled: boolean): void { + if (this.folderShareEnabled === enabled) { + return; + } + this.folderShareEnabled = enabled; + this.refresh(); + } + refreshAfterMutation(): void { this.refresh(); for (const delay of [1_200, 3_500]) { @@ -209,7 +218,9 @@ export class DocFerryTreeProvider implements vscode.TreeDataProvider, if (this.accountState !== "signedIn") { return []; } - const actions = [...START_ACTIONS]; + const actions = START_ACTIONS.filter( + (action) => action.command !== "docferry.shareFolder" || this.folderShareEnabled + ); const detailedNote = this.detailedNotes?.indicator(); if (detailedNote) { actions.splice(1, 0, {