worktrust 0.10.13__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- worktrust-0.10.13.dist-info/METADATA +110 -0
- worktrust-0.10.13.dist-info/RECORD +26 -0
- worktrust-0.10.13.dist-info/WHEEL +5 -0
- worktrust-0.10.13.dist-info/entry_points.txt +2 -0
- worktrust-0.10.13.dist-info/licenses/LICENSE +21 -0
- worktrust-0.10.13.dist-info/top_level.txt +1 -0
- worktrust_cli/__init__.py +88 -0
- worktrust_cli/__main__.py +4 -0
- worktrust_cli/bundle/chat-exports.mjs +240 -0
- worktrust_cli/bundle/config-edits.mjs +87 -0
- worktrust_cli/bundle/count-behaviour.mjs +1393 -0
- worktrust_cli/bundle/log-session.mjs +1330 -0
- worktrust_cli/bundle/manifest.json +19 -0
- worktrust_cli/bundle/preserve-lines.mjs +252 -0
- worktrust_cli/bundle/preserve-summary.mjs +42 -0
- worktrust_cli/bundle/preserve.mjs +390 -0
- worktrust_cli/bundle/runtime-install.mjs +64 -0
- worktrust_cli/bundle/session-databases.mjs +472 -0
- worktrust_cli/bundle/setup-mcp.mjs +343 -0
- worktrust_cli/bundle/stack-rules.mjs +153 -0
- worktrust_cli/bundle/stretch-evidence.mjs +748 -0
- worktrust_cli/bundle/transcript-readers.mjs +269 -0
- worktrust_cli/bundle/worktrust.mjs +1196 -0
- worktrust_cli/runtime/LICENSE +2732 -0
- worktrust_cli/runtime/node.exe +4 -0
- worktrust_cli/runtime/runtime.json +10 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: worktrust
|
|
3
|
+
Version: 0.10.13
|
|
4
|
+
Summary: The WorkTrust CLI, using the same measurement engine as npm
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://worktrust.io
|
|
7
|
+
Project-URL: Source, https://github.com/fhomey/worktrust-cli
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# WorkTrust for Python tool runners
|
|
14
|
+
|
|
15
|
+
This package launches the same JavaScript measurement engine shipped in the WorkTrust npm
|
|
16
|
+
package. It does not implement another measurement engine or a Python instrumentation SDK.
|
|
17
|
+
Python 3.10+ is required. Supported platform wheels include **Node.js 22.23.3**; no separate
|
|
18
|
+
Node or npm installation is needed. The launcher uses that bundled runtime, never downloads a
|
|
19
|
+
runtime when invoked, and does not silently switch to another CLI version.
|
|
20
|
+
|
|
21
|
+
Wheels target macOS 11+ (Intel and Apple Silicon), Linux with glibc 2.28+ (x64 and arm64), and
|
|
22
|
+
Windows 10+ (x64 and arm64). Linux also requires the Node-supported system C++ runtime
|
|
23
|
+
(GLIBCXX_3.4.25 or newer). Alpine/musl, 32-bit systems and other platforms are not supported.
|
|
24
|
+
A source distribution cannot fetch Node automatically: a source build requires the explicit
|
|
25
|
+
maintainer preparation step below. Supported users should install the matching wheel.
|
|
26
|
+
|
|
27
|
+
After publication under a verified WorkTrust-owned PyPI project, the intended commands are:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
uvx worktrust preserve
|
|
31
|
+
pipx run worktrust preserve
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
These public commands are not a claim that this package has already been published. Test a
|
|
35
|
+
reviewed local wheel first:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
uvx --from /absolute/path/worktrust-VERSION-py3-none-PLATFORM.whl worktrust --help
|
|
39
|
+
pipx run --spec /absolute/path/worktrust-VERSION-py3-none-PLATFORM.whl worktrust --help
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Use your package manager to choose and update the installed version. The package contains
|
|
43
|
+
the CLI modules and Node runtime, so `preserve` itself needs no network after installation. It reads supported
|
|
44
|
+
local session files and can change retention settings or create local metadata archives;
|
|
45
|
+
only the person runs it. Existing WorkTrust agent safeguards and consent prompts remain.
|
|
46
|
+
Other commands can contact WorkTrust. Package installation itself can contact registries.
|
|
47
|
+
|
|
48
|
+
After PyPI publication, explicitly request the latest published package when needed:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
uvx worktrust@latest preserve
|
|
52
|
+
pipx run --no-cache worktrust preserve
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
For persistent installations, use `uv tool upgrade worktrust` or `pipx upgrade worktrust`.
|
|
56
|
+
An existing cache or version constraint can otherwise keep an older release. `worktrust update`
|
|
57
|
+
refreshes the computer coupling using the selected package; it does not upgrade the Python package.
|
|
58
|
+
|
|
59
|
+
Runtime bundle hashes detect missing or changed files; they are not hardware attestation or
|
|
60
|
+
independent proof of authenticity. Request and archive signatures retain the existing CLI's
|
|
61
|
+
software-key threat model. See the npm package SECURITY.md and README.md for the data contract.
|
|
62
|
+
|
|
63
|
+
## Maintainers
|
|
64
|
+
|
|
65
|
+
Running `node tools/worktrust-cli/sync.mjs` from the monorepo also regenerates the Python JavaScript
|
|
66
|
+
bundle and version. `node scripts/build-python-cli.mjs --check` refuses drift. The native runtime
|
|
67
|
+
is a separate build artifact, excluded from Git and sdists. Explicitly prepare it before building:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
python tools/worktrust-python/build_runtime.py
|
|
71
|
+
python -m build --wheel tools/worktrust-python
|
|
72
|
+
python -m build --sdist tools/worktrust-python
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`build_runtime.py` downloads one official Node archive from nodejs.org at build time, verifies its
|
|
76
|
+
committed SHA256 from `runtime-lock.json`, and extracts only the executable and complete LICENSE.
|
|
77
|
+
It accepts `--target` for a reviewed target and `--cache` for a build cache. Platform wheel tags
|
|
78
|
+
follow the selected runtime. Never label these wheels `any`. Building a wheel from the sdist
|
|
79
|
+
fails closed until this explicit preparation step has been performed; no install hook downloads code.
|
|
80
|
+
|
|
81
|
+
Run `python tools/worktrust-python/tests/test_runtime_build.py`, then
|
|
82
|
+
`node scripts/test-python-cli.mjs` with a Python containing the build requirements (select it with
|
|
83
|
+
`WORKTRUST_TEST_PYTHON`). Tests build and exercise a real wheel using its own runtime with no Node
|
|
84
|
+
on PATH; they use synthetic homes and metadata only. Native execution must pass independently on
|
|
85
|
+
all six claimed OS/architecture combinations before release. Cross-building alone is not that proof.
|
|
86
|
+
Set `WORKTRUST_REQUIRE_TOOL_RUNNERS=1` to require actual uv and pipx wheel smoke tests, and
|
|
87
|
+
`WORKTRUST_TEST_ARTIFACT_DIR` to a fresh directory to retain the exact tested wheel and sdist.
|
|
88
|
+
Existing artifacts are never overwritten. Canonical module bytes match across channels;
|
|
89
|
+
bit-for-bit reproducibility of whole wheel or sdist archives is not asserted.
|
|
90
|
+
|
|
91
|
+
The public `fhomey/worktrust-cli` repository carries this package under `python/`, alongside the
|
|
92
|
+
npm package at its root. There, install `python/requirements-release.txt`, run
|
|
93
|
+
`python python/build_runtime.py`, then `python python/tests/test_distribution.py` with
|
|
94
|
+
`WORKTRUST_TEST_CLI_SOURCE` set to the repository's absolute path and
|
|
95
|
+
`WORKTRUST_REQUIRE_TOOL_RUNNERS=1`. The test derives Node from the built wheel; it does not require
|
|
96
|
+
a system Node installation. Keep build-only workflow results distinct from successful publication.
|
|
97
|
+
|
|
98
|
+
The inactive `release-pypi.yml.example` targets this public layout. Its intended active filename
|
|
99
|
+
is `.github/workflows/release-pypi.yml`, using the protected `pypi` environment and PyPI Trusted
|
|
100
|
+
Publishing. Once activated, the same GitHub release triggers both registries; manual execution
|
|
101
|
+
supports the initial Python publication and retries. npm also requires the supported-platform Python
|
|
102
|
+
artifact/parity tests before publishing. Both channels use the same version and module bytes.
|
|
103
|
+
Approvals and registry failures remain separate; confirm both publications before announcing a
|
|
104
|
+
release as available on both channels. Neither workflow invokes a Vercel deployment.
|
|
105
|
+
|
|
106
|
+
Do not edit generated `bundle/`, `VERSION` or `LICENSE`. Publication requires confirmed
|
|
107
|
+
PyPI ownership and a configured Trusted Publisher; source readiness does not grant that.
|
|
108
|
+
|
|
109
|
+
Official runtime platform requirements: https://github.com/nodejs/node/blob/v22.23.3/BUILDING.md
|
|
110
|
+
Pinned upstream archive checksums: https://nodejs.org/dist/v22.23.3/SHASUMS256.txt
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
worktrust-0.10.13.dist-info/licenses/LICENSE,sha256=Ki1_xmffV4aEorLyoSLIuEneaNNxa6_-34oPERYt8F0,1066
|
|
2
|
+
worktrust_cli/__init__.py,sha256=BykjYnxG83EtV2oUloviRAf6PnnM4li5L9Alz0J9LOk,4109
|
|
3
|
+
worktrust_cli/__main__.py,sha256=K6urUhz9vfSGXuwgaNA4kmUJf0GnO3L30gPDlScqYI0,48
|
|
4
|
+
worktrust_cli/bundle/chat-exports.mjs,sha256=EcjB-fd0kW15t2i4jwdzHcx-d3GohQNoUbn5OFYYVjE,16550
|
|
5
|
+
worktrust_cli/bundle/config-edits.mjs,sha256=DrovF27296-6Bx1uAN4P8ZuisoMYCoAd_ZV6o4we3ZQ,5336
|
|
6
|
+
worktrust_cli/bundle/count-behaviour.mjs,sha256=IN01BZwM90GDoL-8oJYoETJOKqyFysGZ7UNwAkYd43c,108221
|
|
7
|
+
worktrust_cli/bundle/log-session.mjs,sha256=LBzONGAuFfpYLw0s6rv6TBVNErIVW1BhZDXSDxMWxZU,101744
|
|
8
|
+
worktrust_cli/bundle/manifest.json,sha256=Mo_Fk-zKMyAu7BGIMmoKY9dqY3hjC81DC-k2FOxZ02c,1347
|
|
9
|
+
worktrust_cli/bundle/preserve-lines.mjs,sha256=eUVy9N5z9KY3FelnGZkBOhf3SUuSP4FFWgCbQGSvfjg,23827
|
|
10
|
+
worktrust_cli/bundle/preserve-summary.mjs,sha256=Vo_q5_MkbAdfuhTVT1gPrJ7mpUzukYZO0CZyspiuXTc,3326
|
|
11
|
+
worktrust_cli/bundle/preserve.mjs,sha256=OPg72TOFOphv72H05FLCfUo63GAA9ckgHvcPJwtATqw,31466
|
|
12
|
+
worktrust_cli/bundle/runtime-install.mjs,sha256=HtIxahEU8WNYP5nFNwMnztfU6kIoFc44avSVDe6vUT4,4837
|
|
13
|
+
worktrust_cli/bundle/session-databases.mjs,sha256=VD_TuC0o2W14m-Ch3JGGxfr6eD6YuH5bOxeyEVQuK00,35647
|
|
14
|
+
worktrust_cli/bundle/setup-mcp.mjs,sha256=cPY7Yc8eKPsrdHHndo1ZawzxZszcnRzoWe5Y0psuyrw,32284
|
|
15
|
+
worktrust_cli/bundle/stack-rules.mjs,sha256=sjxW5y7Fa6ArF-2JX4aRctmIxGiml-jWAmsOJo9h5Ik,7106
|
|
16
|
+
worktrust_cli/bundle/stretch-evidence.mjs,sha256=WzGm8pt4XSd-kiV1MvPHzuNmp5dZ3sx6dc-67CaG7hM,54279
|
|
17
|
+
worktrust_cli/bundle/transcript-readers.mjs,sha256=MdpJ-NaKu5TsIivVfAO2tP6-Cz_lMRUqZ1ALJCTVrIg,22282
|
|
18
|
+
worktrust_cli/bundle/worktrust.mjs,sha256=sjJaFACn6z1erszrIkdlQwhZ7tRW2axOwtCQf52OUXs,91080
|
|
19
|
+
worktrust_cli/runtime/LICENSE,sha256=jMm7RmsZ_H58yZ0D6d8RMgIf2osB7qJiTFi7Ny2-9XY,148217
|
|
20
|
+
worktrust_cli/runtime/node.exe,sha256=nJJFFmtKjhguC3l9qcIBNhF_8kNo6v8f7INDoSPI2w4,86973768
|
|
21
|
+
worktrust_cli/runtime/runtime.json,sha256=LUbXJqD0rRIQu488si9hojV_nWuABkAictwDmOqsc10,362
|
|
22
|
+
worktrust-0.10.13.dist-info/METADATA,sha256=AzUyejS1ZP6cONyb8OuA1670y-USWPSNnAagPchVmRg,6403
|
|
23
|
+
worktrust-0.10.13.dist-info/WHEEL,sha256=Zx98gwb_dQKckJK3HEOVKnxxD52EfU_PXDG_usMJ2ng,98
|
|
24
|
+
worktrust-0.10.13.dist-info/entry_points.txt,sha256=bVqGAPH_xUP_nIQQmT3ekm324-cLyzqCykjYdI98Dc4,49
|
|
25
|
+
worktrust-0.10.13.dist-info/top_level.txt,sha256=F8SowabDAnIBJZ3OUoVKO0BKeEzsewR7GLlsQfklnsc,14
|
|
26
|
+
worktrust-0.10.13.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 WorkTrust
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
worktrust_cli
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Distribution launcher only. All evidence calculation remains in the Node CLI."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def verified_entry():
|
|
13
|
+
root = Path(__file__).resolve().parent / "bundle"
|
|
14
|
+
manifest_path = root / "manifest.json"
|
|
15
|
+
if manifest_path.is_symlink():
|
|
16
|
+
raise ValueError("bundle manifest must not be a symlink")
|
|
17
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
18
|
+
if set(manifest) != {"version", "files"} or not re.fullmatch(r"\d+\.\d+\.\d+", manifest["version"]):
|
|
19
|
+
raise ValueError("invalid bundle manifest")
|
|
20
|
+
files = manifest["files"]
|
|
21
|
+
if not isinstance(files, dict) or "worktrust.mjs" not in files:
|
|
22
|
+
raise ValueError("invalid bundle file list")
|
|
23
|
+
if set(p.name for p in root.iterdir()) != set(files) | {"manifest.json"}:
|
|
24
|
+
raise ValueError("bundle file list mismatch")
|
|
25
|
+
for name, digest in files.items():
|
|
26
|
+
if not re.fullmatch(r"[a-z][a-z0-9-]*\.mjs", name) or not re.fullmatch(r"[a-f0-9]{64}", digest):
|
|
27
|
+
raise ValueError("invalid bundle file identity")
|
|
28
|
+
path = root / name
|
|
29
|
+
if path.is_symlink() or not path.is_file() or hashlib.sha256(path.read_bytes()).hexdigest() != digest:
|
|
30
|
+
raise ValueError("bundle integrity check failed")
|
|
31
|
+
return root / "worktrust.mjs"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def verified_runtime():
|
|
35
|
+
root = Path(__file__).resolve().parent / "runtime"
|
|
36
|
+
descriptor = root / "runtime.json"
|
|
37
|
+
if root.is_symlink() or descriptor.is_symlink():
|
|
38
|
+
raise ValueError("bundled runtime must not use symlinks")
|
|
39
|
+
metadata = json.loads(descriptor.read_text(encoding="utf-8"))
|
|
40
|
+
if set(metadata) != {"version","platform","executable","archive_sha256","files"}:
|
|
41
|
+
raise ValueError("invalid bundled runtime descriptor")
|
|
42
|
+
system = {"Darwin":"darwin","Linux":"linux","Windows":"win"}.get(platform.system())
|
|
43
|
+
arch = {"x86_64":"x64","amd64":"x64","aarch64":"arm64","arm64":"arm64"}.get(platform.machine().lower())
|
|
44
|
+
if metadata["platform"] != f"{system}-{arch}":
|
|
45
|
+
raise ValueError("bundled runtime does not support this OS/architecture")
|
|
46
|
+
executable = "node.exe" if os.name == "nt" else "node"
|
|
47
|
+
if metadata["executable"] != executable or set(metadata["files"]) != {executable,"LICENSE"}:
|
|
48
|
+
raise ValueError("invalid bundled runtime file list")
|
|
49
|
+
if not re.fullmatch(r"\d+\.\d+\.\d+", metadata["version"]) or not re.fullmatch(r"[a-f0-9]{64}", metadata["archive_sha256"]):
|
|
50
|
+
raise ValueError("invalid bundled runtime identity")
|
|
51
|
+
if {path.name for path in root.iterdir()} != {executable,"LICENSE","runtime.json"}:
|
|
52
|
+
raise ValueError("bundled runtime file list mismatch")
|
|
53
|
+
for name,digest in metadata["files"].items():
|
|
54
|
+
path = root/name
|
|
55
|
+
if path.is_symlink() or not path.is_file() or not re.fullmatch(r"[a-f0-9]{64}",digest) or hashlib.sha256(path.read_bytes()).hexdigest() != digest:
|
|
56
|
+
raise ValueError("bundled runtime integrity check failed")
|
|
57
|
+
return root/executable
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def main():
|
|
61
|
+
try:
|
|
62
|
+
entry = verified_entry()
|
|
63
|
+
node = verified_runtime()
|
|
64
|
+
env = dict(os.environ)
|
|
65
|
+
# Package-manager selection is authoritative. This does not disable agent detection.
|
|
66
|
+
env["WORKTRUST_RELAYED"] = "1"
|
|
67
|
+
env["WORKTRUST_BUNDLED_RUNTIME_DIR"] = str(node.parent)
|
|
68
|
+
argv = [str(node), str(entry), *sys.argv[1:]]
|
|
69
|
+
if os.name != "nt":
|
|
70
|
+
os.execve(node, argv, env)
|
|
71
|
+
child = subprocess.Popen(argv, env=env)
|
|
72
|
+
try:
|
|
73
|
+
return child.wait()
|
|
74
|
+
except KeyboardInterrupt:
|
|
75
|
+
# Windows delivers console Ctrl-C to both processes; allow the child to finish.
|
|
76
|
+
try:
|
|
77
|
+
return child.wait(timeout=5)
|
|
78
|
+
except subprocess.TimeoutExpired:
|
|
79
|
+
child.terminate()
|
|
80
|
+
child.wait()
|
|
81
|
+
return 130
|
|
82
|
+
except (OSError, ValueError, TypeError, KeyError, subprocess.SubprocessError) as error:
|
|
83
|
+
print(f"worktrust: {error}", file=sys.stderr)
|
|
84
|
+
return 1
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
sys.exit(main())
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A WEB CHAT'S DATA EXPORT, READ ONCE FOR TWO READERS (CLI 0.10.10, owner 2026-10-10). ChatGPT (Settings → Data
|
|
3
|
+
* controls → Export data) and Claude on the web (Settings → Privacy → Export data) each hand a person a zip with a
|
|
4
|
+
* `conversations.json` in it. The counter reads it for signals; the session hook reads it as HISTORY, so a person's
|
|
5
|
+
* years of browser chats appear on their record. One reader here, imported by both, so the two cannot drift.
|
|
6
|
+
*
|
|
7
|
+
* WHAT IT NEVER DOES. It opens nothing of the zip but `conversations.json` (a vendor's export carries the account's
|
|
8
|
+
* profile, memories and files beside it: those are never inflated). It hands the text to the counter's rubric on this
|
|
9
|
+
* computer, as it always did, and to the hook it hands clocks, roles and a model name only: the conversation's title
|
|
10
|
+
* and every message stay here. Hours from an export are RECONSTRUCTED, never measured: the file can be edited before
|
|
11
|
+
* it is read and no device key witnessed it, so the record shows them with their origin and never in verified hours.
|
|
12
|
+
*
|
|
13
|
+
* readExport(path) { vendor, conversations, digest } a folder holding conversations.json, the file, or the zip
|
|
14
|
+
* exportSessions(path, …) one session per conversation, in the transcript's own shape (type, timestamp, message)
|
|
15
|
+
* exportRef(vendor, id, day) the stretch's stable name: the same on any computer, so a second send is the same line
|
|
16
|
+
* findExports(dir) the exports lying in one folder (its top level only), by path and vendor
|
|
17
|
+
*/
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import { existsSync, openSync, readSync, closeSync, fstatSync, readdirSync, statSync, constants } from "node:fs";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { basename, join } from "node:path";
|
|
22
|
+
import { inflateRawSync } from "node:zlib";
|
|
23
|
+
|
|
24
|
+
/** The two vendors, by the client name the door reads as their product (chatgpt → chatgpt_web, claude-web → claude_web). */
|
|
25
|
+
export const EXPORT_CLIENTS = ["chatgpt", "claude-web"];
|
|
26
|
+
/** The neutral line each vendor's conversation is filed under: never the conversation's own title. */
|
|
27
|
+
export const EXPORT_TITLES = { chatgpt: "A conversation in ChatGPT", "claude-web": "A conversation in Claude on the web" };
|
|
28
|
+
const PRODUCT_NAMES = { chatgpt: "ChatGPT", "claude-web": "Claude on the web" };
|
|
29
|
+
export const exportProductName = (vendor) => PRODUCT_NAMES[vendor] ?? vendor;
|
|
30
|
+
const FILE = "conversations.json";
|
|
31
|
+
const expand = (path) => String(path).replace(/^~(?=\/|$)/, homedir());
|
|
32
|
+
|
|
33
|
+
// Bound allocations and parsing work before touching untrusted sizes. Large attachment-bearing ZIPs
|
|
34
|
+
// need not fit in memory; only the directory and conversations entry are ever read. No limit override.
|
|
35
|
+
export const EXPORT_LIMITS = Object.freeze({ fileBytes: 2 * 1024 ** 3, directoryBytes: 8 * 1024 ** 2,
|
|
36
|
+
jsonBytes: 64 * 1024 ** 2, compressedBytes: 64 * 1024 ** 2, depth: 64, structure: 1_000_000, conversations: 50_000 });
|
|
37
|
+
const limit = (condition, label) => { if (!condition) throw Error(`export limit exceeded: ${label}`); };
|
|
38
|
+
const damaged = (condition) => { if (!condition) throw Error("a damaged zip archive"); };
|
|
39
|
+
/** One descriptor, bounded reads: reject special files/short reads and bound concurrent file growth. */
|
|
40
|
+
function openExport(path, maximum) {
|
|
41
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
42
|
+
try {
|
|
43
|
+
const stat = fstatSync(fd);
|
|
44
|
+
if (!stat.isFile()) throw Error("export must be a regular file");
|
|
45
|
+
limit(stat.size <= maximum, "file bytes");
|
|
46
|
+
const readAt = (at, length, end = stat.size) => {
|
|
47
|
+
damaged(Number.isSafeInteger(at) && Number.isSafeInteger(length) && at >= 0 && length >= 0 && at + length <= end && end <= stat.size);
|
|
48
|
+
const buffer = Buffer.alloc(length);
|
|
49
|
+
let got = 0;
|
|
50
|
+
while (got < length) { const count = readSync(fd, buffer, got, length - got, at + got); damaged(count > 0); got += count; }
|
|
51
|
+
return buffer;
|
|
52
|
+
};
|
|
53
|
+
return { fd, size: stat.size, readAt };
|
|
54
|
+
} catch (error) { closeSync(fd); throw error; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A ZIP, READ FOR ONE ENTRY. No dependency: the end-of-central-directory record is found from the back, the central
|
|
59
|
+
* directory walked for the one name, and only that entry's bytes inflated (stored or deflated; anything else, and a
|
|
60
|
+
* zip64 archive, is refused by name). Nothing else in the archive is touched.
|
|
61
|
+
*/
|
|
62
|
+
function zipEntry(path, wanted) {
|
|
63
|
+
const { fd, size, readAt } = openExport(path, EXPORT_LIMITS.fileBytes);
|
|
64
|
+
try {
|
|
65
|
+
const tailLength = Math.min(size, 65_557);
|
|
66
|
+
const tail = readAt(size - tailLength, tailLength);
|
|
67
|
+
let eocd = -1;
|
|
68
|
+
for (let at = tail.length - 22; at >= 0; at -= 1) if (tail.readUInt32LE(at) === 0x06054b50 && at + 22 + tail.readUInt16LE(at + 20) === tail.length) { eocd = at; break; }
|
|
69
|
+
if (eocd < 0) throw Error(`not a zip archive: ${path}`);
|
|
70
|
+
const entries = tail.readUInt16LE(eocd + 10), directorySize = tail.readUInt32LE(eocd + 12), directoryAt = tail.readUInt32LE(eocd + 16);
|
|
71
|
+
if (entries === 0xffff || directorySize === 0xffffffff || directoryAt === 0xffffffff) throw Error(`a zip64 archive is not read: ${path}`);
|
|
72
|
+
if (tail.readUInt16LE(eocd + 4) !== 0 || tail.readUInt16LE(eocd + 6) !== 0) throw Error("unsupported zip: multiple disks");
|
|
73
|
+
damaged(tail.readUInt16LE(eocd + 8) === entries);
|
|
74
|
+
limit(directorySize <= EXPORT_LIMITS.directoryBytes, "zip directory bytes");
|
|
75
|
+
damaged(directoryAt + directorySize === size - tailLength + eocd);
|
|
76
|
+
const directory = readAt(directoryAt, directorySize);
|
|
77
|
+
let at = 0, selected = null;
|
|
78
|
+
for (let index = 0; index < entries; index += 1) {
|
|
79
|
+
damaged(at + 46 <= directory.length && directory.readUInt32LE(at) === 0x02014b50);
|
|
80
|
+
const method = directory.readUInt16LE(at + 10), compressed = directory.readUInt32LE(at + 20), uncompressed = directory.readUInt32LE(at + 24);
|
|
81
|
+
const nameLength = directory.readUInt16LE(at + 28), extraLength = directory.readUInt16LE(at + 30), commentLength = directory.readUInt16LE(at + 32), localAt = directory.readUInt32LE(at + 42);
|
|
82
|
+
const flags = directory.readUInt16LE(at + 8), disk = directory.readUInt16LE(at + 34);
|
|
83
|
+
damaged(at + 46 + nameLength + extraLength + commentLength <= directory.length);
|
|
84
|
+
const name = directory.toString("utf8", at + 46, at + 46 + nameLength);
|
|
85
|
+
at += 46 + nameLength + extraLength + commentLength;
|
|
86
|
+
if (basename(name) !== wanted || name.endsWith("/")) continue;
|
|
87
|
+
if (compressed === 0xffffffff || uncompressed === 0xffffffff || localAt === 0xffffffff) throw Error(`a zip64 entry is not read: ${path}`);
|
|
88
|
+
limit(compressed <= EXPORT_LIMITS.compressedBytes, "compressed entry bytes");
|
|
89
|
+
limit(uncompressed <= EXPORT_LIMITS.jsonBytes, "expanded entry bytes");
|
|
90
|
+
if ((flags & (1 | 64)) !== 0 || disk !== 0) throw Error("unsupported zip: encrypted or multi-disk entry");
|
|
91
|
+
damaged(selected === null);
|
|
92
|
+
selected = { name, method, flags, compressed, uncompressed, localAt };
|
|
93
|
+
}
|
|
94
|
+
damaged(at === directory.length);
|
|
95
|
+
if (!selected) return null;
|
|
96
|
+
const { name, method, flags, compressed, uncompressed, localAt } = selected;
|
|
97
|
+
const local = readAt(localAt, 30, directoryAt);
|
|
98
|
+
damaged(local.readUInt32LE(0) === 0x04034b50 && local.readUInt16LE(8) === method && local.readUInt16LE(6) === flags);
|
|
99
|
+
const nameLength = local.readUInt16LE(26), dataAt = localAt + 30 + nameLength + local.readUInt16LE(28);
|
|
100
|
+
damaged(readAt(localAt + 30, nameLength, directoryAt).toString("utf8") === name);
|
|
101
|
+
if (!(flags & 8)) damaged(local.readUInt32LE(18) === compressed && local.readUInt32LE(22) === uncompressed);
|
|
102
|
+
const bytes = readAt(dataAt, compressed, directoryAt);
|
|
103
|
+
if (method === 0) { damaged(compressed === uncompressed); return bytes; }
|
|
104
|
+
if (method !== 8) throw Error(`${wanted} in ${path} is compressed in a way this reader does not open (method ${method})`);
|
|
105
|
+
let inflated;
|
|
106
|
+
try { inflated = inflateRawSync(bytes, { maxOutputLength: Math.max(1, uncompressed) }); }
|
|
107
|
+
catch { throw Error("a damaged zip archive or export limit exceeded: expanded entry bytes"); }
|
|
108
|
+
damaged(inflated.length === uncompressed);
|
|
109
|
+
return inflated;
|
|
110
|
+
} finally { closeSync(fd); }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The bytes of `conversations.json`, wherever the given path keeps them: a folder, the file itself, or the vendor's zip. */
|
|
114
|
+
function exportBytes(given) {
|
|
115
|
+
let path = expand(given);
|
|
116
|
+
let stat; try { stat = statSync(path); } catch { throw Error(`export not found: ${given}`); }
|
|
117
|
+
if (stat.isDirectory()) { path = join(path, FILE); if (!existsSync(path)) throw Error(`no ${FILE} in ${given}`); }
|
|
118
|
+
if (/\.zip$/i.test(path)) { const bytes = zipEntry(path, FILE); if (!bytes) throw Error(`no ${FILE} in ${given}`); return { bytes, path }; }
|
|
119
|
+
const { fd, size, readAt } = openExport(path, EXPORT_LIMITS.jsonBytes);
|
|
120
|
+
try { return { bytes: readAt(0, size), path }; } finally { closeSync(fd); }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Scan bytes before JSON.parse allocates an object graph; escaped quotes/brackets inside strings
|
|
124
|
+
* are content, not structure. JSON.parse remains responsible for syntax and UTF-8 string handling. */
|
|
125
|
+
function boundJson(bytes) {
|
|
126
|
+
let quoted = false, escaped = false, depth = 0, structure = 0;
|
|
127
|
+
for (const byte of bytes) {
|
|
128
|
+
if (quoted) { if (escaped) escaped = false; else if (byte === 92) escaped = true; else if (byte === 34) quoted = false; continue; }
|
|
129
|
+
if (byte === 34) quoted = true;
|
|
130
|
+
else if (byte === 91 || byte === 123) { depth += 1; structure += 1; limit(depth <= EXPORT_LIMITS.depth, "JSON depth"); }
|
|
131
|
+
else if (byte === 93 || byte === 125) depth -= 1;
|
|
132
|
+
else if (byte === 44 || byte === 58) structure += 1;
|
|
133
|
+
limit(structure <= EXPORT_LIMITS.structure, "JSON structure");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Which vendor wrote these conversations, by their shape alone; mixed or unknown shapes are refused, never guessed. */
|
|
138
|
+
export function vendorOf(conversations) {
|
|
139
|
+
if (!Array.isArray(conversations) || conversations.length === 0) return null;
|
|
140
|
+
const shapes = new Set(conversations.map((c) => (c && typeof c === "object" && c.mapping && typeof c.mapping === "object" ? "chatgpt" : c && typeof c === "object" && Array.isArray(c.chat_messages) ? "claude-web" : "unknown")));
|
|
141
|
+
return shapes.size === 1 ? [...shapes][0] : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** One export, read: its vendor by shape, its conversations, and the digest of its bytes (what `sent` is remembered by). */
|
|
145
|
+
export function readExport(given, expect = null) {
|
|
146
|
+
const { bytes, path } = exportBytes(given);
|
|
147
|
+
boundJson(bytes);
|
|
148
|
+
let conversations; try { conversations = JSON.parse(bytes.toString("utf8")); } catch { throw Error(`export unreadable: ${path}`); }
|
|
149
|
+
if (!Array.isArray(conversations)) throw Error(`${path} must contain an array of conversations`);
|
|
150
|
+
limit(conversations.length <= EXPORT_LIMITS.conversations, "conversation count");
|
|
151
|
+
const vendor = vendorOf(conversations);
|
|
152
|
+
if (conversations.length > 0 && !vendor) throw Error(`${path} is neither a ChatGPT nor a Claude export (or mixes the two)`);
|
|
153
|
+
if (expect && vendor && vendor !== expect) throw Error(`${path} is a ${exportProductName(vendor)} export, not ${exportProductName(expect)}`);
|
|
154
|
+
return { vendor: vendor ?? expect, conversations, digest: createHash("sha256").update(bytes).digest("hex"), path };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The kept branch of one ChatGPT conversation, oldest first: from `current_node` up through the parents. */
|
|
158
|
+
function chatgptBranch(conversation) {
|
|
159
|
+
const mapping = conversation?.mapping && typeof conversation.mapping === "object" ? conversation.mapping : {};
|
|
160
|
+
const branch = [];
|
|
161
|
+
const seen = new Set();
|
|
162
|
+
for (let id = conversation?.current_node; id && mapping[id] && !seen.has(id); id = mapping[id].parent) { seen.add(id); branch.push(mapping[id]); }
|
|
163
|
+
return branch.reverse();
|
|
164
|
+
}
|
|
165
|
+
/** A ChatGPT conversation's lines: the person and the model only; system prompts, tools and what the app hides are neither. */
|
|
166
|
+
function chatgptLines(conversation) {
|
|
167
|
+
const lines = [];
|
|
168
|
+
for (const node of chatgptBranch(conversation)) {
|
|
169
|
+
const m = node?.message;
|
|
170
|
+
const role = m?.author?.role;
|
|
171
|
+
if ((role !== "user" && role !== "assistant") || m?.metadata?.is_visually_hidden_from_conversation) continue;
|
|
172
|
+
const parts = Array.isArray(m?.content?.parts) ? m.content.parts.filter((part) => typeof part === "string") : [];
|
|
173
|
+
const text = parts.join("\n");
|
|
174
|
+
if (text.length === 0) continue;
|
|
175
|
+
const seconds = Number(m?.create_time ?? conversation?.create_time);
|
|
176
|
+
const at = Number.isFinite(seconds) && seconds > 0 ? new Date(seconds * 1000).toISOString() : null;
|
|
177
|
+
const model = typeof m?.metadata?.model_slug === "string" ? m.metadata.model_slug : undefined;
|
|
178
|
+
lines.push({ type: role === "user" ? "user" : "assistant", timestamp: at, message: { ...(role === "assistant" && model ? { model } : {}), content: [{ type: "text", text }] } });
|
|
179
|
+
}
|
|
180
|
+
return lines;
|
|
181
|
+
}
|
|
182
|
+
/** A claude.ai conversation's messages in the transcript's own shape; the export names no model. */
|
|
183
|
+
function claudeLines(conversation) {
|
|
184
|
+
const messages = Array.isArray(conversation?.chat_messages) ? conversation.chat_messages : [];
|
|
185
|
+
return messages.map((m) => {
|
|
186
|
+
const text = typeof m?.text === "string" && m.text.length > 0 ? m.text : (Array.isArray(m?.content) ? m.content.filter((c) => c?.type === "text" && typeof c.text === "string").map((c) => c.text).join("\n") : "");
|
|
187
|
+
const at = m?.created_at ?? conversation?.created_at ?? null;
|
|
188
|
+
return { type: m?.sender === "human" ? "user" : "assistant", timestamp: at, message: { content: [{ type: "text", text }] } };
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
const titleOf = (vendor, conversation) => String((vendor === "chatgpt" ? conversation?.title : conversation?.name) ?? "");
|
|
192
|
+
const idOf = (vendor, conversation, index) => String((vendor === "chatgpt" ? conversation?.conversation_id ?? conversation?.id : conversation?.uuid) ?? `#${index}`);
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Every conversation of an export as one session: `key` names the vendor and the conversation's id, `lines` carry
|
|
196
|
+
* the transcript shape both readers measure. `excluded(title)` skips a conversation by its title before anything of
|
|
197
|
+
* it is read (the same `--exclude` as a project directory); the title itself goes no further than that call.
|
|
198
|
+
* `given` is a path, or an export `readExport` already returned (so a zip is inflated once).
|
|
199
|
+
*/
|
|
200
|
+
export function* exportSessions(given, { expect = null, excluded = () => false, skipped = null } = {}) {
|
|
201
|
+
const { vendor, conversations } = typeof given === "string" ? readExport(given, expect) : given;
|
|
202
|
+
for (let index = 0; index < conversations.length; index += 1) {
|
|
203
|
+
const conversation = conversations[index];
|
|
204
|
+
const title = titleOf(vendor, conversation);
|
|
205
|
+
if (excluded(title)) { skipped?.push(title.slice(0, 40)); continue; }
|
|
206
|
+
const lines = vendor === "chatgpt" ? chatgptLines(conversation) : claudeLines(conversation);
|
|
207
|
+
if (lines.length === 0) continue;
|
|
208
|
+
const id = idOf(vendor, conversation, index);
|
|
209
|
+
yield { key: `${vendor === "chatgpt" ? "chatgpt" : "claude.ai"}:${id}`, projectDir: vendor === "chatgpt" ? "chatgpt" : "claude-ai", basename: vendor === "chatgpt" ? "chatgpt" : "claude.ai", vendor, id, lines };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* THE STRETCH'S NAME (the hook's `stretch_ref`, 43 characters): a hash of the vendor, the conversation's id and the
|
|
215
|
+
* UTC day, the same on any computer and on any copy of the export, so the same export sent twice, or from two
|
|
216
|
+
* computers, is each line once. The conversation's id is a vendor's opaque id; the hash carries nothing readable.
|
|
217
|
+
*/
|
|
218
|
+
export const exportRef = (vendor, id, day) => createHash("sha256").update(`worktrust-export|${vendor}|${id}|${day}`).digest("base64url");
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* THE EXPORTS IN ONE FOLDER, its top level only (the Downloads folder, where a vendor's export lands): a zip holding
|
|
222
|
+
* `conversations.json`, a folder holding one, or the file itself. Each is read to tell its vendor and digest; a file
|
|
223
|
+
* that is none of these, or not an export, is passed over without a word.
|
|
224
|
+
*/
|
|
225
|
+
export function findExports(dir = join(homedir(), "Downloads")) {
|
|
226
|
+
const root = expand(dir);
|
|
227
|
+
let names = []; try { names = readdirSync(root); } catch { return []; }
|
|
228
|
+
const found = [];
|
|
229
|
+
for (const name of names.sort()) {
|
|
230
|
+
const path = join(root, name);
|
|
231
|
+
let stat; try { stat = statSync(path); } catch { continue; }
|
|
232
|
+
const candidate = stat.isDirectory() ? existsSync(join(path, FILE)) : /\.zip$/i.test(name) || name === FILE;
|
|
233
|
+
if (!candidate) continue;
|
|
234
|
+
try {
|
|
235
|
+
const { vendor, conversations, digest } = readExport(path);
|
|
236
|
+
if (vendor && conversations.length > 0) found.push({ path, vendor, digest, conversations: conversations.length });
|
|
237
|
+
} catch { /* not an export, or not one this reader opens */ }
|
|
238
|
+
}
|
|
239
|
+
return found;
|
|
240
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CONFIG EDITS — a YAML file edited line by line, never re-serialised (2026-10-05).
|
|
3
|
+
*
|
|
4
|
+
* Hermes Agent and Goose keep their settings in YAML, written by people, with comments and their own indentation.
|
|
5
|
+
* setup-mcp.mjs puts WorkTrust's entry into them and takes it out again with these: a section is found by its key at
|
|
6
|
+
* a known indent, an entry is added at the indent its siblings use, and a removal leaves every other byte as it was.
|
|
7
|
+
* Only block style is handled; a section written inline (`key: { … }`) makes placeUnder return false, and the caller
|
|
8
|
+
* leaves the file alone. No dependency: it ships beside setup-mcp.mjs.
|
|
9
|
+
*/
|
|
10
|
+
const indentOf = (line) => line.length - line.trimStart().length;
|
|
11
|
+
const meaningful = (line) => line.trim() !== "" && !line.trimStart().startsWith("#");
|
|
12
|
+
/** `key:` at `indent` between start and stop: its line and the end of its block, or the inline value it carries. */
|
|
13
|
+
function section(lines, start, stop, indent, key) {
|
|
14
|
+
for (let i = start; i < stop; i += 1) {
|
|
15
|
+
if (!meaningful(lines[i]) || indentOf(lines[i]) !== indent) continue;
|
|
16
|
+
const match = new RegExp(`^\\s*${key}:(?:\\s+(.*?))?\\s*$`).exec(lines[i]);
|
|
17
|
+
if (!match) continue;
|
|
18
|
+
const inline = match[1] && !match[1].startsWith("#") ? match[1] : null;
|
|
19
|
+
let end = i + 1;
|
|
20
|
+
while (end < stop && (!meaningful(lines[end]) || indentOf(lines[end]) > indent || (indentOf(lines[end]) === indent && lines[end].trimStart().startsWith("-")))) end += 1;
|
|
21
|
+
while (end > i + 1 && !meaningful(lines[end - 1])) end -= 1;
|
|
22
|
+
return { at: i, end, inline };
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
/** The entries of a block (a mapping's keys or a list's items), each [from, to). */
|
|
27
|
+
function entries(lines, at, end) {
|
|
28
|
+
const starts = [];
|
|
29
|
+
const first = lines.slice(at + 1, end).find(meaningful);
|
|
30
|
+
const child = first === undefined ? null : indentOf(first);
|
|
31
|
+
for (let i = at + 1; i < end; i += 1) if (meaningful(lines[i]) && indentOf(lines[i]) === child) starts.push(i);
|
|
32
|
+
return { child, ranges: starts.map((from, n) => [from, starts[n + 1] ?? end]) };
|
|
33
|
+
}
|
|
34
|
+
const EMPTY_INLINE = new Set(["{}", "[]", "null", "~"]);
|
|
35
|
+
/** Puts `unit` under the path, making each section that is missing; false when one is written inline. */
|
|
36
|
+
export function placeUnder(lines, path, unit) {
|
|
37
|
+
let start = 0, stop = lines.length, indent = 0;
|
|
38
|
+
for (const [n, key] of path.entries()) {
|
|
39
|
+
let found = section(lines, start, stop, indent, key);
|
|
40
|
+
if (found?.inline && !EMPTY_INLINE.has(found.inline)) return false;
|
|
41
|
+
if (found?.inline) lines[found.at] = `${" ".repeat(indent)}${key}:`;
|
|
42
|
+
if (!found) { lines.splice(stop, 0, `${" ".repeat(indent)}${key}:`); found = { at: stop, end: stop + 1 }; }
|
|
43
|
+
const child = entries(lines, found.at, found.end).child ?? indent + 2;
|
|
44
|
+
if (n === path.length - 1) { lines.splice(found.end, 0, ...unit.map((line) => " ".repeat(child) + line)); return true; }
|
|
45
|
+
start = found.at + 1; stop = found.end; indent = child;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
/** The section at the end of a path, in block style; null when absent or inline. */
|
|
50
|
+
function find(lines, path) {
|
|
51
|
+
let start = 0, stop = lines.length, indent = 0, found = null;
|
|
52
|
+
for (const key of path) {
|
|
53
|
+
found = section(lines, start, stop, indent, key);
|
|
54
|
+
if (!found || found.inline) return null;
|
|
55
|
+
start = found.at + 1; stop = found.end; indent = entries(lines, found.at, found.end).child ?? indent + 2;
|
|
56
|
+
}
|
|
57
|
+
return found;
|
|
58
|
+
}
|
|
59
|
+
/** Takes out every entry under the path that is ours, and a section that leaves empty. */
|
|
60
|
+
export function takeOut(lines, path, mine) {
|
|
61
|
+
const found = find(lines, path);
|
|
62
|
+
if (!found) return;
|
|
63
|
+
let removed = 0;
|
|
64
|
+
for (const [from, to] of entries(lines, found.at, found.end).ranges.reverse()) if (mine(lines.slice(from, to).join("\n"))) { lines.splice(from, to - from); removed += 1; }
|
|
65
|
+
if (removed > 0) for (let depth = path.length; depth > 0; depth -= 1) {
|
|
66
|
+
const left = find(lines, path.slice(0, depth));
|
|
67
|
+
if (left && !lines.slice(left.at + 1, left.end).some(meaningful)) lines.splice(left.at, left.end - left.at);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* ONLY THE HOUSE'S OWN DOOR (0.6.16, audit L5; the same rule as log-session.mjs and count-behaviour.mjs): an MCP address is
|
|
73
|
+
* WorkTrust's when its path is /api/mcp and its host is worktrust.io or a subdomain over https, the host WORKTRUST_MCP_URL
|
|
74
|
+
* names, or WorkTrust's own port on this computer. `/api/mcp` alone is every hosted MCP server's conventional path, so another product's entry is
|
|
75
|
+
* never adopted as the coupling, and never taken out with it.
|
|
76
|
+
*/
|
|
77
|
+
/** WorkTrust's own local doors (the app in development, 3011; a module branch, 3021). Another product's local MCP server
|
|
78
|
+
* (the Advisory Board on 3007, 2026-10-09) is not one: it was offered for removal on update, and could have been adopted. */
|
|
79
|
+
const LOCAL_DOOR_PORTS = new Set(["3011", "3021"]);
|
|
80
|
+
export const isWorkTrustDoor = (url) => {
|
|
81
|
+
try {
|
|
82
|
+
const parsed = new URL(String(url));
|
|
83
|
+
if (!/\/api\/mcp\/?$/.test(parsed.pathname)) return false;
|
|
84
|
+
const own = process.env.WORKTRUST_MCP_URL ? new URL(process.env.WORKTRUST_MCP_URL).host : null;
|
|
85
|
+
return parsed.protocol === "https:" && (parsed.hostname === "worktrust.io" || parsed.hostname.endsWith(".worktrust.io")) || (own !== null && parsed.host === own) || ((parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") && LOCAL_DOOR_PORTS.has(parsed.port));
|
|
86
|
+
} catch { return false; }
|
|
87
|
+
};
|