unaltraweb 0.5.1 → 0.7.0

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.
@@ -3,18 +3,28 @@ set -eu
3
3
 
4
4
  usage() {
5
5
  cat <<'USAGE'
6
- Usage: unaltraweb-mcp-bootstrap [--project PATH] [--image IMAGE] [--prepare|--check|--smoke]
6
+ Usage: unaltraweb-mcp-bootstrap [--project PATH] [--host-project PATH] [--image IMAGE]
7
+ [--expected-image-id ID] [--managed] [--offline] [--session-id ID] [--worker-images JSON]
8
+ [--prepare|--check|--smoke]
7
9
 
8
10
  Start one Dockerized unaltraweb MCP stdio server for the selected workspace.
9
11
  If --project is omitted, MCP_CONSUMER_WORKSPACE is used, then the current directory.
10
12
  --prepare inspects or pulls the selected image without mounting a consumer.
11
- --check and --smoke require that image locally; neither builds nor pulls.
13
+ Launch, --check and --smoke require that exact image locally; none builds or pulls.
14
+ Managed mode requires an immutable reference or an expected full image ID.
12
15
  USAGE
13
16
  }
14
17
 
15
- image="${UNALTRAWEB_MCP_IMAGE:-ghcr.io/dosquartsdedocs/unaltraweb-mcp@sha256:36d17edbade77edb40a687f6a744203c6329acb33fbc2eb255e88d9ff1a42c98}"
18
+ image="${UNALTRAWEB_MCP_IMAGE:-ghcr.io/dosquartsdedocs/unaltraweb-mcp@sha256:736c4ddd0a543454e3edaeac2e9cfd97a1279ce472923ac54e326c1281b2ba05}"
16
19
  project="${MCP_CONSUMER_WORKSPACE:-${UNALTRAWEB_PROJECT:-}}"
17
20
  operation=serve
21
+ expected_image="${UNALTRAWEB_EXPECTED_IMAGE_ID:-}"
22
+ managed="${UNALTRAWEB_MANAGED_RUNTIME:-0}"
23
+ session_id=""
24
+ host_project=""
25
+ network=bridge
26
+ worker_images="${UNALTRAWEB_WORKER_IMAGES:-}"
27
+ [ -n "$worker_images" ] || worker_images='{}'
18
28
 
19
29
  while [ "$#" -gt 0 ]; do
20
30
  case "$1" in
@@ -28,6 +38,18 @@ while [ "$#" -gt 0 ]; do
28
38
  image="$2"
29
39
  shift 2
30
40
  ;;
41
+ --host-project|--expected-image-id|--session-id|--worker-images)
42
+ [ "$#" -ge 2 ] || { printf 'Missing value for %s\n' "$1" >&2; exit 2; }
43
+ case "$1" in
44
+ --host-project) host_project=$2 ;;
45
+ --expected-image-id) expected_image=$2 ;;
46
+ --session-id) session_id=$2 ;;
47
+ --worker-images) worker_images=$2 ;;
48
+ esac
49
+ shift 2
50
+ ;;
51
+ --managed) managed=1; shift ;;
52
+ --offline) network=none; shift ;;
31
53
  --prepare|--check|--smoke)
32
54
  [ "$operation" = serve ] || { printf '%s\n' 'Select only one operation.' >&2; exit 2; }
33
55
  operation="${1#--}"
@@ -45,10 +67,33 @@ while [ "$#" -gt 0 ]; do
45
67
  esac
46
68
  done
47
69
 
70
+ valid_image_id() {
71
+ case "$1" in sha256:*) hex=${1#sha256:} ;; *) return 1 ;; esac
72
+ [ "${#hex}" -eq 64 ] || return 1
73
+ case "$hex" in *[!0-9a-f]*) return 1 ;; esac
74
+ }
75
+ case "$image" in ''|*[!A-Za-z0-9._/:@-]*) printf '%s\n' 'Invalid image reference' >&2; exit 2 ;; esac
76
+ case "$image" in *://*) printf '%s\n' 'Docker references are not URLs' >&2; exit 2 ;; esac
77
+ case "$image" in
78
+ *@*)
79
+ valid_image_id "${image##*@}" || { printf '%s\n' 'Invalid digest reference' >&2; exit 2; }
80
+ case "${image%@*}" in *@*) printf '%s\n' 'Invalid digest reference' >&2; exit 2 ;; esac
81
+ ;;
82
+ esac
83
+ if [ -n "$expected_image" ]; then
84
+ valid_image_id "$expected_image" || { printf '%s\n' 'Expected image ID must be a full lowercase sha256' >&2; exit 2; }
85
+ managed=1
86
+ fi
87
+ case "$image" in
88
+ sha256:*) valid_image_id "$image" || exit 2; managed=1 ;;
89
+ *@sha256:*) valid_image_id "sha256:${image##*@sha256:}" || exit 2; managed=1 ;;
90
+ *) if [ "$managed" = 1 ] && [ -z "$expected_image" ]; then printf '%s\n' 'Managed mutable references require --expected-image-id' >&2; exit 2; fi ;;
91
+ esac
92
+
48
93
  resolve_image() {
49
94
  if ! resolved_image="$(docker image inspect --format '{{.Id}}' "$image" 2>/dev/null)"; then
50
95
  case "$operation" in
51
- check|smoke)
96
+ serve|check|smoke)
52
97
  printf 'Image is not prepared: %s. Run --prepare first.\n' "$image" >&2
53
98
  exit 1
54
99
  ;;
@@ -56,14 +101,19 @@ resolve_image() {
56
101
  docker pull "$image" >&2
57
102
  resolved_image="$(docker image inspect --format '{{.Id}}' "$image")"
58
103
  fi
104
+ valid_image_id "$resolved_image" || { printf '%s\n' 'Docker returned an invalid image identity' >&2; exit 2; }
105
+ case "$image" in sha256:*) [ "$image" = "$resolved_image" ] || { printf '%s\n' 'Selected image ID mismatch' >&2; exit 1; } ;; esac
106
+ if [ -n "$expected_image" ] && [ "$expected_image" != "$resolved_image" ]; then
107
+ printf '%s\n' 'Prepared image does not match the expected image ID; no fallback was attempted' >&2; exit 1
108
+ fi
59
109
  }
60
110
 
61
111
  if [ "$operation" != serve ]; then
62
112
  resolve_image
63
113
  case "$operation" in
64
114
  prepare) printf '%s\n' "$resolved_image"; exit 0 ;;
65
- check) exec docker run --rm --network none --user "$(id -u):$(id -g)" -e HOME=/tmp --entrypoint unaltraweb-mcp "$resolved_image" version ;;
66
- smoke) exec docker run --rm --network none --user "$(id -u):$(id -g)" -e HOME=/tmp --entrypoint python3 "$resolved_image" /opt/unaltraweb/test/mcp_smoke.py ;;
115
+ check) exec docker run --rm --pull never --network none --user "$(id -u):$(id -g)" -e HOME=/tmp --entrypoint unaltraweb-mcp "$resolved_image" version ;;
116
+ smoke) exec docker run --rm --pull never --network none --user "$(id -u):$(id -g)" -e HOME=/tmp --entrypoint python3 "$resolved_image" /opt/unaltraweb/test/mcp_smoke.py ;;
67
117
  esac
68
118
  fi
69
119
 
@@ -85,10 +135,28 @@ if [ ! -d "$project" ]; then
85
135
  fi
86
136
 
87
137
  project="$(CDPATH= cd -- "$project" && pwd -P)"
138
+ launcher_project="$project"
139
+ if [ -z "$host_project" ]; then
140
+ host_project="${UNALTRAWEB_DOCKER_ROOT:-}"
141
+ if [ -n "$host_project" ]; then
142
+ inherited_workspace="${MCP_CONSUMER_WORKSPACE:-}"
143
+ if [ -z "$inherited_workspace" ] || [ ! -d "$inherited_workspace" ] || [ "$(CDPATH= cd -- "$inherited_workspace" && pwd -P)" != "$launcher_project" ]; then
144
+ printf '%s\n' 'Inherited daemon mapping belongs to another or unknown workspace; pass --host-project explicitly' >&2; exit 2
145
+ fi
146
+ fi
147
+ fi
148
+ if [ -z "$host_project" ] && [ -f /.dockerenv ]; then
149
+ printf '%s\n' 'A containerized launcher needs --host-project with the explicit daemon-host mapping' >&2; exit 2
150
+ fi
151
+ host_project="${host_project:-$project}"
152
+ case "$host_project" in /*) ;; *) printf '%s\n' 'Host project must be absolute' >&2; exit 2 ;; esac
153
+ case "$host_project" in *"$newline"*|*"$carriage_return"*) exit 2 ;; esac
154
+ [ "$host_project" != / ] && [ "$(realpath -ms -- "$host_project")" = "$host_project" ] || { printf '%s\n' 'Host project must be normalized' >&2; exit 2; }
155
+ project="$host_project"
88
156
  owner="${UNALTRAWEB_PROJECT_USER:-$(id -u):$(id -g)}"
89
157
  docker_socket="${UNALTRAWEB_DOCKER_SOCKET:-/var/run/docker.sock}"
90
158
  script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)"
91
- project_id="$(/bin/sh "$script_dir/unaltraweb-mcp-project-id.sh" "$project")"
159
+ project_id="$(printf '%s' "$project" | sha256sum | cut -c 1-16)"
92
160
  workspace_mount="$(/bin/sh "$script_dir/unaltraweb-docker-mount.sh" "$project" /workspace)"
93
161
  mirror_mount="$(/bin/sh "$script_dir/unaltraweb-docker-mount.sh" "$project" "$project")"
94
162
 
@@ -103,7 +171,7 @@ if [ -S "$docker_socket" ]; then
103
171
  exit 1
104
172
  fi
105
173
  case "$docker_socket" in
106
- "$project"|"$project"/*)
174
+ "$project"|"$project"/*|"$launcher_project"|"$launcher_project"/*)
107
175
  printf '%s\n' 'UNALTRAWEB_DOCKER_SOCKET must be outside the consumer project so the persistent preview cannot inherit it.' >&2
108
176
  exit 1
109
177
  ;;
@@ -111,6 +179,9 @@ if [ -S "$docker_socket" ]; then
111
179
  fi
112
180
 
113
181
  resolve_image
182
+ if [ -z "$session_id" ]; then session_id=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n'); fi
183
+ [ "${#session_id}" -eq 32 ] || { printf '%s\n' 'Session ID must contain 32 lowercase hex characters' >&2; exit 2; }
184
+ case "$session_id" in *[!0-9a-f]*) exit 2 ;; esac
114
185
  image_reference="$image"
115
186
  case "$image_reference" in
116
187
  ghcr.io/dosquartsdedocs/unaltraweb-mcp@sha256:*) ;;
@@ -123,10 +194,13 @@ case "$image_reference" in
123
194
  ;;
124
195
  esac
125
196
 
126
- set -- docker run --rm -i \
197
+ set -- docker run --rm --pull never --init -i \
198
+ --network "$network" \
199
+ --name "unaltraweb-stdio-$session_id" --cpus 2 --memory 4g --pids-limit 512 \
127
200
  --label io.context.mcp-factory=unaltraweb \
128
201
  --label io.context.mcp-role=stdio \
129
202
  --label "io.context.mcp-project=$project_id" \
203
+ --label "io.context.mcp-session=$session_id" \
130
204
  --user "$owner" \
131
205
  -e HOME=/tmp \
132
206
  -e COMPUTE_CORE=/opt/unaltraweb \
@@ -136,6 +210,13 @@ set -- docker run --rm -i \
136
210
  -e UNALTRAWEB_FACTORY_DIR=/opt/unaltraweb \
137
211
  -e "UNALTRAWEB_MCP_IMAGE=$resolved_image" \
138
212
  -e "UNALTRAWEB_MCP_IMAGE_REFERENCE=$image_reference" \
213
+ -e "UNALTRAWEB_MCP_REQUESTED_IMAGE=$image" \
214
+ -e "UNALTRAWEB_EXPECTED_IMAGE_ID=$resolved_image" \
215
+ -e "UNALTRAWEB_RUNTIME_SESSION=$session_id" \
216
+ -e "UNALTRAWEB_MANAGED_RUNTIME=$managed" \
217
+ -e "UNALTRAWEB_RUNTIME_NETWORK=$network" \
218
+ -e "UNALTRAWEB_LAUNCHER_PROJECT=$launcher_project" \
219
+ -e "UNALTRAWEB_WORKER_IMAGES=$worker_images" \
139
220
  -e "UNALTRAWEB_PROJECT_USER=$owner" \
140
221
  --mount "$workspace_mount" \
141
222
  --mount "$mirror_mount" \
@@ -58,16 +58,48 @@ case "$project_id" in
58
58
  ;;
59
59
  esac
60
60
 
61
- containers=$(docker ps -aq \
61
+ # Explicit workspace-wide shutdown. Managed per-client detach uses live drain,
62
+ # stdio EOF and the separate session-status/reap-session native adapter instead.
63
+ containers=$(docker ps -aq --no-trunc \
62
64
  --filter "label=io.context.mcp-factory=unaltraweb" \
63
65
  --filter "label=io.context.mcp-project=$project_id")
64
- if [ -n "$containers" ]; then
65
- docker rm -f $containers >/dev/null
66
- fi
67
-
68
- networks=$(docker network ls -q \
66
+ networks=$(docker network ls -q --no-trunc \
69
67
  --filter "label=io.context.mcp-factory=unaltraweb" \
70
68
  --filter "label=io.context.mcp-project=$project_id")
71
- if [ -n "$networks" ]; then
72
- docker network rm $networks >/dev/null
73
- fi
69
+
70
+ valid_id() {
71
+ [ "${#1}" -eq 64 ] || return 1
72
+ case "$1" in *[!0-9a-f]*) return 1 ;; esac
73
+ }
74
+ count=0
75
+ for cid in $containers; do
76
+ count=$((count + 1))
77
+ [ "$count" -le 64 ] && valid_id "$cid" || { printf '%s\n' 'Container inventory is invalid or exceeds bounds' >&2; exit 2; }
78
+ labels=$(docker container inspect --format '{{index .Config.Labels "io.context.mcp-factory"}}|{{index .Config.Labels "io.context.mcp-role"}}|{{index .Config.Labels "io.context.mcp-project"}}' "$cid")
79
+ case "$labels" in
80
+ "unaltraweb|stdio|$project_id"|"unaltraweb|preview|$project_id"|"unaltraweb|computation|$project_id"|"unaltraweb|manual-pdf|$project_id"|"unaltraweb|web-capture|$project_id"|"unaltraweb|web-capture-site|$project_id") ;;
81
+ *) printf '%s\n' 'Refusing unknown container ownership' >&2; exit 2 ;;
82
+ esac
83
+ done
84
+ for nid in $networks; do
85
+ count=$((count + 1))
86
+ [ "$count" -le 64 ] && valid_id "$nid" || { printf '%s\n' 'Network inventory is invalid or exceeds bounds' >&2; exit 2; }
87
+ labels=$(docker network inspect --format '{{index .Labels "io.context.mcp-factory"}}|{{index .Labels "io.context.mcp-role"}}|{{index .Labels "io.context.mcp-project"}}' "$nid")
88
+ [ "$labels" = "unaltraweb|web-capture|$project_id" ] || { printf '%s\n' 'Refusing unknown network ownership' >&2; exit 2; }
89
+ done
90
+ if [ -n "$containers" ]; then docker rm -f $containers >/dev/null; fi
91
+ if [ -n "$networks" ]; then docker network rm $networks >/dev/null; fi
92
+ for cid in $containers; do
93
+ if observed=$(docker container inspect --format '{{.Id}}' "$cid" 2>&1); then
94
+ printf '%s\n' 'Container termination is not confirmed' >&2; exit 1
95
+ else
96
+ case "$observed" in *"No such container"*|*"no such container"*|*"No such object"*) ;; *) printf '%s\n' 'Container absence is unknown' >&2; exit 1 ;; esac
97
+ fi
98
+ done
99
+ for nid in $networks; do
100
+ if observed=$(docker network inspect --format '{{.Id}}' "$nid" 2>&1); then
101
+ printf '%s\n' 'Network removal is not confirmed' >&2; exit 1
102
+ else
103
+ case "$observed" in *"No such network"*|*"no such network"*|*"No such object"*) ;; *) printf '%s\n' 'Network absence is unknown' >&2; exit 1 ;; esac
104
+ fi
105
+ done
@@ -588,6 +588,8 @@ def internal_network(network: str) -> bool:
588
588
 
589
589
  def build_image(image: str) -> None:
590
590
  available = inspect_image(image)["available"] == "true"
591
+ if not available and os.environ.get("UNALTRAWEB_MANAGED_RUNTIME") == "1":
592
+ raise WebCaptureError("Selected capture image is not prepared; managed execution never pulls")
591
593
  if available:
592
594
  return
593
595
  completed = subprocess.run(["docker", "pull", image], check=False)
@@ -722,6 +724,9 @@ def run_worker(project: Path, image: str, config_path: Path) -> None:
722
724
  "project": os.environ.get("UNALTRAWEB_WORKER_PROJECT", "").strip(),
723
725
  "token": os.environ.get("UNALTRAWEB_WORKER_TOKEN", "").strip(),
724
726
  }
727
+ session = os.environ.get("UNALTRAWEB_RUNTIME_SESSION", "")
728
+ if session and not re.fullmatch(r"[0-9a-f]{32}", session):
729
+ raise WebCaptureError("Invalid runtime session identity")
725
730
  worker_args: list[str] = []
726
731
  cidfile: Path | None = None
727
732
  if any(worker_values.values()):
@@ -739,7 +744,9 @@ def run_worker(project: Path, image: str, config_path: Path) -> None:
739
744
  "--label", f"io.context.mcp-worker-token={worker_values['token']}",
740
745
  ]
741
746
  command = [
742
- "docker", "run", "--rm", *worker_args, "--user", f"{os.getuid()}:{os.getgid()}", "--network", network, "--ipc=host",
747
+ "docker", "run", "--rm", "--pull", "never", *worker_args,
748
+ *(["--label", f"io.context.mcp-session={session}"] if session else []),
749
+ "--user", f"{os.getuid()}:{os.getgid()}", "--network", network, "--ipc=host",
743
750
  "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--pids-limit", "256", "--cpus", "2", "--memory", "2g",
744
751
  "--tmpfs", "/tmp:rw,noexec,nosuid,size=512m", "-e", "HOME=/tmp",
745
752
  "--mount", docker_bind_mount(project, "/project"), "-w", "/project", image,
@@ -0,0 +1,4 @@
1
+ from .distribution import distribution_version
2
+
3
+
4
+ __version__ = distribution_version()
@@ -0,0 +1,102 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "MCP artifact handoff v1 (review candidate)",
4
+ "oneOf": [
5
+ {"$ref": "#/$defs/bundle"},
6
+ {"$ref": "#/$defs/integration"}
7
+ ],
8
+ "$defs": {
9
+ "id": {"type": "string", "pattern": "^[a-z][a-z0-9._-]{0,79}$"},
10
+ "hash": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
11
+ "revision": {"type": "string", "pattern": "^(git:([0-9a-f]{40}|[0-9a-f]{64})|sha256:[0-9a-f]{64})$"},
12
+ "path": {"type": "string", "minLength": 1, "maxLength": 1024},
13
+ "runtime": {
14
+ "type": "object", "additionalProperties": false,
15
+ "required": ["name", "revision"],
16
+ "properties": {
17
+ "name": {"$ref": "#/$defs/id"},
18
+ "revision": {"$ref": "#/$defs/revision"}
19
+ }
20
+ },
21
+ "actor": {
22
+ "type": "object", "additionalProperties": false,
23
+ "required": ["name", "version", "revision", "runtimes"],
24
+ "properties": {
25
+ "name": {"$ref": "#/$defs/id"},
26
+ "version": {"type": "string", "minLength": 1, "maxLength": 80, "pattern": "^[A-Za-z0-9][A-Za-z0-9._+-]*$"},
27
+ "revision": {"$ref": "#/$defs/revision"},
28
+ "runtimes": {"type": "array", "maxItems": 32, "items": {"$ref": "#/$defs/runtime"}}
29
+ }
30
+ },
31
+ "dependency": {
32
+ "type": "object", "additionalProperties": false,
33
+ "required": ["id", "sha256"],
34
+ "properties": {
35
+ "id": {"$ref": "#/$defs/id"},
36
+ "sha256": {"$ref": "#/$defs/hash"}
37
+ }
38
+ },
39
+ "upstream": {
40
+ "type": "object", "additionalProperties": false,
41
+ "required": ["dependency", "file"],
42
+ "properties": {
43
+ "dependency": {"$ref": "#/$defs/id"},
44
+ "file": {"$ref": "#/$defs/id"}
45
+ }
46
+ },
47
+ "file": {
48
+ "type": "object", "additionalProperties": false,
49
+ "required": ["id", "path", "kind", "role", "ownership", "sha256", "bytes"],
50
+ "properties": {
51
+ "id": {"$ref": "#/$defs/id"},
52
+ "path": {"$ref": "#/$defs/path"},
53
+ "kind": {"enum": ["input", "output", "evidence"]},
54
+ "role": {"$ref": "#/$defs/id"},
55
+ "ownership": {"enum": ["author", "producer"]},
56
+ "sha256": {"$ref": "#/$defs/hash"},
57
+ "bytes": {"type": "integer", "minimum": 0, "maximum": 536870912},
58
+ "variant_of": {"$ref": "#/$defs/id"},
59
+ "from": {"$ref": "#/$defs/upstream"}
60
+ }
61
+ },
62
+ "bundle": {
63
+ "type": "object", "additionalProperties": false,
64
+ "required": ["schema_version", "kind", "producer", "request", "files", "dependencies"],
65
+ "properties": {
66
+ "schema_version": {"type": "integer", "const": 1},
67
+ "kind": {"const": "mcp-artifact-bundle"},
68
+ "producer": {"$ref": "#/$defs/actor"},
69
+ "request": {"$ref": "#/$defs/id"},
70
+ "files": {"type": "array", "minItems": 2, "maxItems": 10000, "items": {"$ref": "#/$defs/file"}},
71
+ "dependencies": {"type": "array", "maxItems": 63, "items": {"$ref": "#/$defs/dependency"}}
72
+ }
73
+ },
74
+ "retained_bundle": {
75
+ "type": "object", "additionalProperties": false,
76
+ "required": ["path", "sha256"],
77
+ "properties": {
78
+ "path": {"$ref": "#/$defs/path"},
79
+ "sha256": {"$ref": "#/$defs/hash"}
80
+ }
81
+ },
82
+ "mapping": {
83
+ "type": "object", "additionalProperties": false,
84
+ "required": ["source", "destination"],
85
+ "properties": {
86
+ "source": {"$ref": "#/$defs/path"},
87
+ "destination": {"$ref": "#/$defs/path"}
88
+ }
89
+ },
90
+ "integration": {
91
+ "type": "object", "additionalProperties": false,
92
+ "required": ["schema_version", "kind", "integrator", "bundle", "mappings"],
93
+ "properties": {
94
+ "schema_version": {"type": "integer", "const": 1},
95
+ "kind": {"const": "mcp-artifact-integration"},
96
+ "integrator": {"$ref": "#/$defs/actor"},
97
+ "bundle": {"$ref": "#/$defs/retained_bundle"},
98
+ "mappings": {"type": "array", "minItems": 1, "maxItems": 10000, "items": {"$ref": "#/$defs/mapping"}}
99
+ }
100
+ }
101
+ }
102
+ }