workpeg 0.78.0__tar.gz → 0.79.0__tar.gz
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.
- {workpeg-0.78.0/src/workpeg.egg-info → workpeg-0.79.0}/PKG-INFO +1 -1
- {workpeg-0.78.0 → workpeg-0.79.0}/pyproject.toml +1 -1
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/cdn.py +10 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/context.py +21 -2
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/run.py +4 -1
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/submit.py +40 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/compiler/ast_parser.py +6 -5
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/fake_cloud.py +28 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/runtime/ai.js +43 -3
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/runtime/workpeg-runtime.js +14 -4
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/serve.py +10 -3
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/widgets/interactive.py +3 -0
- {workpeg-0.78.0 → workpeg-0.79.0/src/workpeg.egg-info}/PKG-INFO +1 -1
- {workpeg-0.78.0 → workpeg-0.79.0}/tests/test_cdn.py +31 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/tests/test_context.py +80 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/tests/test_run.py +3 -3
- {workpeg-0.78.0 → workpeg-0.79.0}/tests/test_submit.py +62 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/LICENSE +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/MANIFEST.in +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/README.md +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/setup.cfg +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/__init__.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/build.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/cli.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/config.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/create_new.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/runtime.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/templates/__init__.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/templates/functions/Dockerfile +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/templates/functions/LICENSE +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/templates/functions/README.md +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/templates/functions/app/__init__.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/templates/functions/app/main.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/templates/functions/requirements.txt +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/__init__.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/actions.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/app.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/build.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/compiler/__init__.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/compiler/ir.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/compiler/js_generator.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/create.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/minify.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/runtime/pegment.css +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/scaffold.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/state.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/validators.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/watcher.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/widgets/__init__.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/widgets/base.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/widgets/canvas.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/widgets/display.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/widgets/forms.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/widgets/futures.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/widgets/gestures.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/ui/widgets/layout.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg/utils.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg.egg-info/SOURCES.txt +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg.egg-info/dependency_links.txt +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg.egg-info/entry_points.txt +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg.egg-info/requires.txt +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/src/workpeg.egg-info/top_level.txt +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/tests/test_build.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/tests/test_changelog.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/tests/test_changelog_guard.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/tests/test_cli.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/tests/test_config.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/tests/test_create_new.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/tests/test_release_script.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/tests/test_runtime.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/tests/test_utils.py +0 -0
- {workpeg-0.78.0 → workpeg-0.79.0}/tests/test_watch_pipeline.py +0 -0
|
@@ -9,6 +9,8 @@ from pathlib import Path
|
|
|
9
9
|
|
|
10
10
|
import requests
|
|
11
11
|
|
|
12
|
+
from workpeg.submit import _looks_secret
|
|
13
|
+
|
|
12
14
|
|
|
13
15
|
DEFAULT_API_BASE = "https://repo.workpeg.com"
|
|
14
16
|
|
|
@@ -21,6 +23,10 @@ EXCLUDED_DIRS = {
|
|
|
21
23
|
"venv",
|
|
22
24
|
"dist",
|
|
23
25
|
"build",
|
|
26
|
+
# credential stores — never publish these
|
|
27
|
+
".ssh",
|
|
28
|
+
".aws",
|
|
29
|
+
".gnupg",
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
EXCLUDED_SUFFIXES = (".pyc", ".pyo")
|
|
@@ -79,6 +85,10 @@ def build_directory_tar_gz_bytes(path: Path) -> bytes:
|
|
|
79
85
|
if item.name.endswith(EXCLUDED_SUFFIXES):
|
|
80
86
|
continue
|
|
81
87
|
|
|
88
|
+
# Never publish secrets, and never follow a symlink out of the tree.
|
|
89
|
+
if item.is_symlink() or _looks_secret(item.name):
|
|
90
|
+
continue
|
|
91
|
+
|
|
82
92
|
archive.add(item, arcname=str(rel))
|
|
83
93
|
|
|
84
94
|
return out.getvalue()
|
|
@@ -62,7 +62,16 @@ class _TableContext(BaseContext):
|
|
|
62
62
|
|
|
63
63
|
@classmethod
|
|
64
64
|
def _verify_columns(cls, columns):
|
|
65
|
-
|
|
65
|
+
# A structural check, not a full schema: columns is a list of column
|
|
66
|
+
# definitions (each an object). Defense-in-depth on data the platform
|
|
67
|
+
# serialises in — reject a clearly-wrong shape, don't second-guess
|
|
68
|
+
# each field.
|
|
69
|
+
if columns is None:
|
|
70
|
+
return
|
|
71
|
+
if not isinstance(columns, list):
|
|
72
|
+
raise ValueError("Columns must be a list of column definitions.")
|
|
73
|
+
if not all(isinstance(c, dict) for c in columns):
|
|
74
|
+
raise ValueError("Each column must be a dictionary.")
|
|
66
75
|
|
|
67
76
|
@classmethod
|
|
68
77
|
def _verify_namespace(cls, namespace):
|
|
@@ -71,7 +80,17 @@ class _TableContext(BaseContext):
|
|
|
71
80
|
|
|
72
81
|
@classmethod
|
|
73
82
|
def _verify_peg(cls, peg):
|
|
74
|
-
|
|
83
|
+
# A peg identifier: a positive integer id (the common case) or a
|
|
84
|
+
# namespace string. bool is an int subclass in Python, so exclude it
|
|
85
|
+
# explicitly.
|
|
86
|
+
if peg is None:
|
|
87
|
+
return
|
|
88
|
+
if isinstance(peg, bool) or not isinstance(peg, (int, str)):
|
|
89
|
+
raise ValueError("Peg must be an integer id, a string, or None.")
|
|
90
|
+
if isinstance(peg, int) and peg <= 0:
|
|
91
|
+
raise ValueError("Peg id must be a positive integer.")
|
|
92
|
+
if isinstance(peg, str) and not peg.strip():
|
|
93
|
+
raise ValueError("Peg string must be non-empty.")
|
|
75
94
|
|
|
76
95
|
|
|
77
96
|
class _StaffContext(BaseContext):
|
|
@@ -44,7 +44,10 @@ def run_with_docker(
|
|
|
44
44
|
cmd += ["--name", name]
|
|
45
45
|
|
|
46
46
|
if expose:
|
|
47
|
-
|
|
47
|
+
# Publish to LOOPBACK only — a locally-run function's /invoke shouldn't
|
|
48
|
+
# be reachable from the LAN. (The deployed runtime binds 0.0.0.0 inside
|
|
49
|
+
# its own managed container; this is the developer's machine.)
|
|
50
|
+
cmd += ["-p", f"127.0.0.1:{port}:{port}"]
|
|
48
51
|
|
|
49
52
|
if network:
|
|
50
53
|
cmd += ["--network", network]
|
|
@@ -16,6 +16,26 @@ JWT_ENV = "WORKPEG_PK"
|
|
|
16
16
|
API_ENV = "WORKPEG_API_BASE"
|
|
17
17
|
PATH_ENV = "WORKPEG_SUBMIT_PATH"
|
|
18
18
|
|
|
19
|
+
# Files that must never leave the machine in a bundle/upload — env files, key
|
|
20
|
+
# material, and credential stores. Matched by basename (case-insensitively).
|
|
21
|
+
_SECRET_NAMES = frozenset({
|
|
22
|
+
".envrc", ".netrc", ".npmrc", ".pypirc", ".htpasswd", ".dockercfg",
|
|
23
|
+
"credentials", "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519",
|
|
24
|
+
})
|
|
25
|
+
_SECRET_SUFFIXES = (".pem", ".key", ".pfx", ".p12", ".keystore", ".jks")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _looks_secret(name: str) -> bool:
|
|
29
|
+
"""A basename that looks like a secret (``.env``/``.env.local``, a key file,
|
|
30
|
+
a credential store) — excluded from bundles and uploads."""
|
|
31
|
+
lo = name.lower()
|
|
32
|
+
return (
|
|
33
|
+
lo == ".env"
|
|
34
|
+
or lo.startswith(".env.")
|
|
35
|
+
or lo in _SECRET_NAMES
|
|
36
|
+
or lo.endswith(_SECRET_SUFFIXES)
|
|
37
|
+
)
|
|
38
|
+
|
|
19
39
|
|
|
20
40
|
def _fail(msg: str, code: int = 2) -> None:
|
|
21
41
|
print(f"ERROR: {msg}", file=sys.stderr)
|
|
@@ -91,8 +111,13 @@ def build_tar_gz_bytes(source_dir: Path) -> bytes:
|
|
|
91
111
|
"dist",
|
|
92
112
|
"build",
|
|
93
113
|
".DS_Store",
|
|
114
|
+
# credential stores — never ship these off the machine
|
|
115
|
+
".ssh",
|
|
116
|
+
".aws",
|
|
117
|
+
".gnupg",
|
|
94
118
|
}
|
|
95
119
|
|
|
120
|
+
skipped_secrets: list[str] = []
|
|
96
121
|
buf = io.BytesIO()
|
|
97
122
|
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
98
123
|
for p in source_dir.rglob("*"):
|
|
@@ -101,12 +126,27 @@ def build_tar_gz_bytes(source_dir: Path) -> bytes:
|
|
|
101
126
|
if any(part in exclude_names for part in rel.parts):
|
|
102
127
|
continue
|
|
103
128
|
|
|
129
|
+
# Never bundle secrets, and never follow symlinks out of the project
|
|
130
|
+
# (a link could point at anything on the machine). Belt-and-braces on
|
|
131
|
+
# top of validate_function_project.
|
|
132
|
+
if p.is_symlink():
|
|
133
|
+
skipped_secrets.append(rel.as_posix() + " (symlink)")
|
|
134
|
+
continue
|
|
135
|
+
if _looks_secret(rel.name):
|
|
136
|
+
skipped_secrets.append(rel.as_posix())
|
|
137
|
+
continue
|
|
138
|
+
|
|
104
139
|
if not p.exists():
|
|
105
140
|
continue
|
|
106
141
|
|
|
107
142
|
arcname = rel.as_posix()
|
|
108
143
|
tar.add(p.as_posix(), arcname=arcname, recursive=False)
|
|
109
144
|
|
|
145
|
+
if skipped_secrets:
|
|
146
|
+
print(
|
|
147
|
+
" Skipped (looks like a secret / symlink, not bundled): "
|
|
148
|
+
+ ", ".join(sorted(skipped_secrets))
|
|
149
|
+
)
|
|
110
150
|
return buf.getvalue()
|
|
111
151
|
|
|
112
152
|
|
|
@@ -76,10 +76,11 @@ _WORKPEG_TABLE_HELPERS = {
|
|
|
76
76
|
|
|
77
77
|
|
|
78
78
|
def _is_workpeg_host(url: str) -> bool:
|
|
79
|
-
"""True when *url* targets a Workpeg-owned host — ``workpeg``
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
"""True when *url* targets a Workpeg-owned host — ``workpeg`` as the
|
|
80
|
+
REGISTRABLE label, the one right before the TLD (workpeg.com, *.workpeg.io,
|
|
81
|
+
…), not merely somewhere in the name (so ``foo.workpeg.evil.com`` is NOT
|
|
82
|
+
Workpeg). Relative URLs have no host and are never Workpeg. Mirrors
|
|
83
|
+
``Workpeg._isWorkpegHost`` in the runtime."""
|
|
83
84
|
try:
|
|
84
85
|
host = urlparse(url).hostname
|
|
85
86
|
except ValueError:
|
|
@@ -87,7 +88,7 @@ def _is_workpeg_host(url: str) -> bool:
|
|
|
87
88
|
if not host:
|
|
88
89
|
return False
|
|
89
90
|
labels = host.lower().split(".")
|
|
90
|
-
return
|
|
91
|
+
return len(labels) >= 2 and labels[-2] == "workpeg"
|
|
91
92
|
|
|
92
93
|
|
|
93
94
|
_SUPPORTED_WIDGETS = {
|
|
@@ -47,6 +47,30 @@ def slugify(name: str) -> str:
|
|
|
47
47
|
return re.sub(r"[^a-z0-9-]", "", slug).strip("-")
|
|
48
48
|
|
|
49
49
|
|
|
50
|
+
# A plain hostname: dot-separated LDH labels, ≤253 chars. Anything else — a
|
|
51
|
+
# space, newline, slash, shell metachar — is rejected before it reaches a hosts
|
|
52
|
+
# file (where a newline could inject another mapping), a cert subject, or a path.
|
|
53
|
+
_VALID_DOMAIN = re.compile(
|
|
54
|
+
r"^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
|
|
55
|
+
r"(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$",
|
|
56
|
+
re.IGNORECASE,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _require_valid_domain(domain: str) -> None:
|
|
61
|
+
if not isinstance(domain, str) or not _VALID_DOMAIN.match(domain):
|
|
62
|
+
raise FakeCloudError(f"Refusing to use an invalid hostname: {domain!r}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _lock_down_key(key: "Path") -> None:
|
|
66
|
+
"""Make a generated TLS private key owner read/write only (0600). A no-op
|
|
67
|
+
where POSIX permissions don't apply."""
|
|
68
|
+
try:
|
|
69
|
+
os.chmod(key, 0o600)
|
|
70
|
+
except OSError:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
50
74
|
def derive_domain(name: str) -> str:
|
|
51
75
|
"""Turn a project name into ``dev.<slug>.workpeg.com``.
|
|
52
76
|
|
|
@@ -107,6 +131,7 @@ def ensure_hosts_entry(domain: str, hosts_path: "str | None" = None) -> bool:
|
|
|
107
131
|
terminal. Entries are tagged with a marker comment so they are easy
|
|
108
132
|
to find and remove later.
|
|
109
133
|
"""
|
|
134
|
+
_require_valid_domain(domain)
|
|
110
135
|
path = hosts_path or hosts_path_for_os()
|
|
111
136
|
content = Path(path).read_text()
|
|
112
137
|
if _has_hosts_entry(content, domain):
|
|
@@ -151,6 +176,7 @@ def ensure_certificate(
|
|
|
151
176
|
``"self-signed"`` (openssl fallback — browser warning). Certificates
|
|
152
177
|
are cached under ``~/.workpeg/fake-cloud/<domain>/`` and reused.
|
|
153
178
|
"""
|
|
179
|
+
_require_valid_domain(domain) # it lands in a path, a subject, a SAN
|
|
154
180
|
root = Path(cert_root) if cert_root else _CERT_ROOT
|
|
155
181
|
cert_dir = root / domain
|
|
156
182
|
cert = cert_dir / "cert.pem"
|
|
@@ -178,6 +204,7 @@ def ensure_certificate(
|
|
|
178
204
|
check=True,
|
|
179
205
|
capture_output=True,
|
|
180
206
|
)
|
|
207
|
+
_lock_down_key(key)
|
|
181
208
|
kind_file.write_text("mkcert")
|
|
182
209
|
return str(cert), str(key), "mkcert"
|
|
183
210
|
except subprocess.CalledProcessError:
|
|
@@ -198,6 +225,7 @@ def ensure_certificate(
|
|
|
198
225
|
check=True,
|
|
199
226
|
capture_output=True,
|
|
200
227
|
)
|
|
228
|
+
_lock_down_key(key)
|
|
201
229
|
kind_file.write_text("self-signed")
|
|
202
230
|
return str(cert), str(key), "self-signed"
|
|
203
231
|
|
|
@@ -212,6 +212,43 @@
|
|
|
212
212
|
});
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
// Momentary "copied" confirmation on an icon copy button: swap its glyph to a
|
|
216
|
+
// check and its label to "Copied", then restore after a beat. Re-clicking
|
|
217
|
+
// resets the timer instead of stacking them, and the resting icon/label are
|
|
218
|
+
// captured only on the first flash so rapid clicks can't lose them.
|
|
219
|
+
function flashCopied(btn) {
|
|
220
|
+
if (!btn) return;
|
|
221
|
+
var ic = btn.querySelector("i");
|
|
222
|
+
if (btn._copiedTimer) {
|
|
223
|
+
clearTimeout(btn._copiedTimer);
|
|
224
|
+
} else if (ic) {
|
|
225
|
+
btn._restIcon = ic.className;
|
|
226
|
+
btn._restLabel = btn.getAttribute("aria-label");
|
|
227
|
+
}
|
|
228
|
+
if (ic) ic.className = "ti ti-check";
|
|
229
|
+
btn.classList.add("is-copied");
|
|
230
|
+
btn.setAttribute("aria-label", "Copied");
|
|
231
|
+
btn.setAttribute("title", "Copied");
|
|
232
|
+
btn._copiedTimer = setTimeout(function () {
|
|
233
|
+
if (ic) ic.className = btn._restIcon || "ti ti-copy";
|
|
234
|
+
btn.classList.remove("is-copied");
|
|
235
|
+
if (btn._restLabel) {
|
|
236
|
+
btn.setAttribute("aria-label", btn._restLabel);
|
|
237
|
+
btn.setAttribute("title", btn._restLabel);
|
|
238
|
+
}
|
|
239
|
+
btn._copiedTimer = null;
|
|
240
|
+
}, 1200);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// An icon copy button that confirms the copy with a momentary check.
|
|
244
|
+
function copyBtn(getText, extraCls) {
|
|
245
|
+
var btn = iconBtn("copy", "Copy", function () {
|
|
246
|
+
copyText(getText());
|
|
247
|
+
flashCopied(btn);
|
|
248
|
+
}, extraCls);
|
|
249
|
+
return btn;
|
|
250
|
+
}
|
|
251
|
+
|
|
215
252
|
function escapeHtml(s) {
|
|
216
253
|
return String(s == null ? "" : s)
|
|
217
254
|
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
@@ -666,7 +703,7 @@
|
|
|
666
703
|
var bubble = el("div", "chat-message__bubble", { text: m.content });
|
|
667
704
|
var actions = el("div", "chat-message__actions chat-message__actions--user", {
|
|
668
705
|
children: [
|
|
669
|
-
|
|
706
|
+
copyBtn(function () { return m.content; })
|
|
670
707
|
]
|
|
671
708
|
});
|
|
672
709
|
return el("div", "chat-message chat-message--user", { children: [
|
|
@@ -732,8 +769,8 @@
|
|
|
732
769
|
|
|
733
770
|
function buildAiActions(node) {
|
|
734
771
|
var m = node._msg;
|
|
735
|
-
var copy =
|
|
736
|
-
|
|
772
|
+
var copy = copyBtn(function () {
|
|
773
|
+
return node._bubble.innerText || m.content; }, "chat-message__action");
|
|
737
774
|
var regen = iconBtn("refresh", "Regenerate", function () { regenerate(); },
|
|
738
775
|
"chat-message__action");
|
|
739
776
|
var right = el("div", "chat-message__actions-right", { children: [
|
|
@@ -1187,6 +1224,9 @@
|
|
|
1187
1224
|
R + " .wpai-iconbtn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:transparent;border:none;color:var(--color-icon-tertiary);cursor:pointer;border-radius:var(--radius,8px);transition:all .15s ease}",
|
|
1188
1225
|
R + " .wpai-iconbtn:hover{background:var(--color-surface-sunken2);color:var(--color-icon-primary)}",
|
|
1189
1226
|
R + " .wpai-iconbtn.is-active{color:var(--color-brand-secondary,var(--color-brand-primary))}",
|
|
1227
|
+
// Momentary confirmation after a copy — the check reads as success, and this
|
|
1228
|
+
// wins over :hover (defined earlier) so it stays green even under the cursor.
|
|
1229
|
+
R + " .wpai-iconbtn.is-copied,.wpai-iconbtn.is-copied:hover{color:var(--color-success-500,#12b76a);background:transparent}",
|
|
1190
1230
|
// chips + suggestions
|
|
1191
1231
|
R + " .suggestions-section{display:grid;gap:16px;border-radius:var(--radius-lg,12px);padding:16px;width:100%}",
|
|
1192
1232
|
R + " .chips-title{font-size:var(--text-body-sm-size,13px);color:var(--color-text-secondary);margin:0}",
|
|
@@ -49,7 +49,11 @@
|
|
|
49
49
|
Workpeg._isWorkpegHost = function (url) {
|
|
50
50
|
var host = urlHost(url);
|
|
51
51
|
if (!host) return false;
|
|
52
|
-
|
|
52
|
+
// "workpeg" must be the REGISTRABLE label — the one right before the TLD.
|
|
53
|
+
// So workpeg.com and dev.x.workpeg.io match, but foo.workpeg.evil.com does
|
|
54
|
+
// NOT: a bare "workpeg" label buried in an attacker's domain is not us.
|
|
55
|
+
var labels = host.split(".");
|
|
56
|
+
return labels.length >= 2 && labels[labels.length - 2] === "workpeg";
|
|
53
57
|
};
|
|
54
58
|
|
|
55
59
|
// Core request: NO Workpeg-host guard (workpeg.* uses this directly).
|
|
@@ -117,8 +121,10 @@
|
|
|
117
121
|
(window.location && window.location.hostname) || ""
|
|
118
122
|
).toLowerCase();
|
|
119
123
|
var labels = host.split(".");
|
|
120
|
-
|
|
121
|
-
|
|
124
|
+
// Only a genuine Workpeg host (workpeg as the registrable label) names a
|
|
125
|
+
// namespace — the label right before "workpeg".
|
|
126
|
+
var i = labels.length - 2;
|
|
127
|
+
return (i > 0 && labels[i] === "workpeg") ? labels[i - 1] : "";
|
|
122
128
|
} catch (e) {
|
|
123
129
|
return "";
|
|
124
130
|
}
|
|
@@ -407,7 +413,7 @@
|
|
|
407
413
|
var ep = (window.location && window.location.pathname) || "/";
|
|
408
414
|
window.location.href =
|
|
409
415
|
Workpeg.accountsBase.replace(/\/+$/, "") +
|
|
410
|
-
"/login?peg=" + encodeURIComponent(ns) + "&ep=" + ep;
|
|
416
|
+
"/login?peg=" + encodeURIComponent(ns) + "&ep=" + encodeURIComponent(ep);
|
|
411
417
|
} catch (e) { /* no window — nothing to redirect */ }
|
|
412
418
|
};
|
|
413
419
|
|
|
@@ -4398,6 +4404,10 @@
|
|
|
4398
4404
|
}
|
|
4399
4405
|
if (props.target) {
|
|
4400
4406
|
el.target = props.target;
|
|
4407
|
+
// A new browsing context must not get a usable window.opener back to us
|
|
4408
|
+
// (reverse tabnabbing) and shouldn't leak our URL as Referer — match
|
|
4409
|
+
// HtmlView/Markdown, which already set this.
|
|
4410
|
+
if (props.target === "_blank") el.rel = "noopener noreferrer";
|
|
4401
4411
|
}
|
|
4402
4412
|
} else {
|
|
4403
4413
|
el = document.createElement("div");
|
|
@@ -370,11 +370,18 @@ def find_free_port(start: int = 8080) -> int:
|
|
|
370
370
|
)
|
|
371
371
|
|
|
372
372
|
|
|
373
|
+
def _bind_host() -> str:
|
|
374
|
+
"""Interface the dev server listens on. Localhost by default, so the app,
|
|
375
|
+
live-reload endpoint and error overlay aren't exposed to the whole LAN; set
|
|
376
|
+
``WORKPEG_BIND_HOST=0.0.0.0`` to reach it from another device on purpose."""
|
|
377
|
+
return os.environ.get("WORKPEG_BIND_HOST", "127.0.0.1").strip() or "127.0.0.1"
|
|
378
|
+
|
|
379
|
+
|
|
373
380
|
def _create_server(
|
|
374
381
|
directory: str, port: int
|
|
375
382
|
) -> tuple[_ReusingTCPServer, str]:
|
|
376
383
|
abs_dir = str(Path(directory).resolve())
|
|
377
|
-
server = _ReusingTCPServer((
|
|
384
|
+
server = _ReusingTCPServer((_bind_host(), port), _make_handler(abs_dir))
|
|
378
385
|
return server, f"http://localhost:{port}"
|
|
379
386
|
|
|
380
387
|
|
|
@@ -457,7 +464,7 @@ def serve_background(
|
|
|
457
464
|
if watch_source is not None:
|
|
458
465
|
abs_dir = str(Path(directory).resolve())
|
|
459
466
|
handler_cls, broadcast = _make_reload_handler(abs_dir)
|
|
460
|
-
server: socketserver.BaseServer = _ReloadTCPServer((
|
|
467
|
+
server: socketserver.BaseServer = _ReloadTCPServer((_bind_host(), port), handler_cls)
|
|
461
468
|
|
|
462
469
|
from workpeg.ui.watcher import FileWatcher
|
|
463
470
|
|
|
@@ -510,7 +517,7 @@ def serve_blocking(
|
|
|
510
517
|
if watch_source is not None:
|
|
511
518
|
abs_dir = str(Path(directory).resolve())
|
|
512
519
|
handler_cls, broadcast = _make_reload_handler(abs_dir)
|
|
513
|
-
server: socketserver.BaseServer = _ReloadTCPServer((
|
|
520
|
+
server: socketserver.BaseServer = _ReloadTCPServer((_bind_host(), port), handler_cls)
|
|
514
521
|
|
|
515
522
|
from workpeg.ui.watcher import FileWatcher
|
|
516
523
|
|
|
@@ -118,6 +118,9 @@ class Link(BaseWidget):
|
|
|
118
118
|
p["label"] = self.label
|
|
119
119
|
if self.target is not None:
|
|
120
120
|
p["target"] = self.target
|
|
121
|
+
if self.aria_label:
|
|
122
|
+
# camelCase — the key the generator/runtime read (props.ariaLabel).
|
|
123
|
+
p["ariaLabel"] = self.aria_label
|
|
121
124
|
if self.icon is not None:
|
|
122
125
|
p["icon"] = self.icon
|
|
123
126
|
return p
|
|
@@ -85,6 +85,37 @@ def test_build_tar_includes_files_and_excludes_junk(tmp_path):
|
|
|
85
85
|
assert "mod.pyc" not in names # .pyc suffix excluded
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
def test_build_tar_excludes_secrets_and_symlinks(tmp_path):
|
|
89
|
+
(tmp_path / "index.html").write_text("hi")
|
|
90
|
+
# Secrets a developer might leave in an asset dir — never publish these.
|
|
91
|
+
(tmp_path / ".env").write_text("SECRET=1")
|
|
92
|
+
(tmp_path / "server.key").write_text("key")
|
|
93
|
+
(tmp_path / "id_rsa").write_text("key")
|
|
94
|
+
(tmp_path / ".ssh").mkdir()
|
|
95
|
+
(tmp_path / ".ssh" / "config").write_text("x")
|
|
96
|
+
|
|
97
|
+
# A symlink escaping the tree must not be followed.
|
|
98
|
+
outside = tmp_path.parent / "outside.txt"
|
|
99
|
+
outside.write_text("secret")
|
|
100
|
+
symlink_supported = True
|
|
101
|
+
try:
|
|
102
|
+
(tmp_path / "escape.txt").symlink_to(outside)
|
|
103
|
+
except (OSError, NotImplementedError):
|
|
104
|
+
symlink_supported = False
|
|
105
|
+
|
|
106
|
+
data = build_directory_tar_gz_bytes(tmp_path)
|
|
107
|
+
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
|
|
108
|
+
names = set(tar.getnames())
|
|
109
|
+
|
|
110
|
+
assert "index.html" in names
|
|
111
|
+
assert ".env" not in names
|
|
112
|
+
assert "server.key" not in names
|
|
113
|
+
assert "id_rsa" not in names
|
|
114
|
+
assert not any(n.startswith(".ssh") for n in names)
|
|
115
|
+
if symlink_supported:
|
|
116
|
+
assert "escape.txt" not in names
|
|
117
|
+
|
|
118
|
+
|
|
88
119
|
def test_build_tar_missing_dir_exits(tmp_path):
|
|
89
120
|
with pytest.raises(SystemExit):
|
|
90
121
|
build_directory_tar_gz_bytes(tmp_path / "does-not-exist")
|
|
@@ -199,3 +199,83 @@ class TestMetadataContext:
|
|
|
199
199
|
|
|
200
200
|
for case in test_cases:
|
|
201
201
|
assert _MetadataContext.verify(case) is True
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class TestTableColumnsValidation:
|
|
205
|
+
|
|
206
|
+
def test_accepts_empty_list(self, valid_table_data):
|
|
207
|
+
valid_table_data["columns"] = []
|
|
208
|
+
assert _TableContext.verify(valid_table_data) is True
|
|
209
|
+
|
|
210
|
+
def test_accepts_list_of_dicts(self, valid_table_data):
|
|
211
|
+
valid_table_data["columns"] = [{"name": "a"}, {"name": "b"}]
|
|
212
|
+
assert _TableContext.verify(valid_table_data) is True
|
|
213
|
+
|
|
214
|
+
def test_accepts_none(self, valid_table_data):
|
|
215
|
+
valid_table_data["columns"] = None
|
|
216
|
+
assert _TableContext.verify(valid_table_data) is True
|
|
217
|
+
|
|
218
|
+
def test_rejects_non_list(self, valid_table_data):
|
|
219
|
+
valid_table_data["columns"] = {"not": "a list"}
|
|
220
|
+
with pytest.raises(
|
|
221
|
+
ValueError,
|
|
222
|
+
match="Columns must be a list of column definitions.",
|
|
223
|
+
):
|
|
224
|
+
_TableContext.verify(valid_table_data)
|
|
225
|
+
|
|
226
|
+
def test_rejects_non_dict_entries(self, valid_table_data):
|
|
227
|
+
valid_table_data["columns"] = ["name", "age"]
|
|
228
|
+
with pytest.raises(
|
|
229
|
+
ValueError,
|
|
230
|
+
match="Each column must be a dictionary.",
|
|
231
|
+
):
|
|
232
|
+
_TableContext.verify(valid_table_data)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class TestTablePegValidation:
|
|
236
|
+
|
|
237
|
+
def test_accepts_positive_int(self, valid_table_data):
|
|
238
|
+
valid_table_data["peg"] = 10
|
|
239
|
+
assert _TableContext.verify(valid_table_data) is True
|
|
240
|
+
|
|
241
|
+
def test_accepts_namespace_string(self, valid_table_data):
|
|
242
|
+
valid_table_data["peg"] = "workpeg.hr"
|
|
243
|
+
assert _TableContext.verify(valid_table_data) is True
|
|
244
|
+
|
|
245
|
+
def test_accepts_none(self, valid_table_data):
|
|
246
|
+
valid_table_data["peg"] = None
|
|
247
|
+
assert _TableContext.verify(valid_table_data) is True
|
|
248
|
+
|
|
249
|
+
def test_rejects_zero_and_negative(self, valid_table_data):
|
|
250
|
+
for bad in (0, -5):
|
|
251
|
+
valid_table_data["peg"] = bad
|
|
252
|
+
with pytest.raises(
|
|
253
|
+
ValueError,
|
|
254
|
+
match="Peg id must be a positive integer.",
|
|
255
|
+
):
|
|
256
|
+
_TableContext.verify(valid_table_data)
|
|
257
|
+
|
|
258
|
+
def test_rejects_bool(self, valid_table_data):
|
|
259
|
+
# bool is an int subclass — must not sneak through as a peg id.
|
|
260
|
+
valid_table_data["peg"] = True
|
|
261
|
+
with pytest.raises(
|
|
262
|
+
ValueError,
|
|
263
|
+
match="Peg must be an integer id, a string, or None.",
|
|
264
|
+
):
|
|
265
|
+
_TableContext.verify(valid_table_data)
|
|
266
|
+
|
|
267
|
+
def test_rejects_wrong_type(self, valid_table_data):
|
|
268
|
+
valid_table_data["peg"] = {"id": 1}
|
|
269
|
+
with pytest.raises(
|
|
270
|
+
ValueError,
|
|
271
|
+
match="Peg must be an integer id, a string, or None.",
|
|
272
|
+
):
|
|
273
|
+
_TableContext.verify(valid_table_data)
|
|
274
|
+
|
|
275
|
+
def test_rejects_blank_string(self, valid_table_data):
|
|
276
|
+
valid_table_data["peg"] = " "
|
|
277
|
+
with pytest.raises(
|
|
278
|
+
ValueError,
|
|
279
|
+
match="Peg string must be non-empty.",
|
|
280
|
+
):
|
|
281
|
+
_TableContext.verify(valid_table_data)
|
|
@@ -53,7 +53,7 @@ def test_run_with_docker_builds_first(monkeypatch, tmp_path):
|
|
|
53
53
|
cmd, check = calls[0]
|
|
54
54
|
assert cmd == [
|
|
55
55
|
"docker", "run", "-d",
|
|
56
|
-
"-p", "9000:9000",
|
|
56
|
+
"-p", "127.0.0.1:9000:9000",
|
|
57
57
|
"--network", "workpeg_net",
|
|
58
58
|
"--restart", "unless-stopped",
|
|
59
59
|
"built-image",
|
|
@@ -132,7 +132,7 @@ def test_run_with_docker_no_build(monkeypatch, tmp_path):
|
|
|
132
132
|
cmd, check = calls[0]
|
|
133
133
|
assert cmd == [
|
|
134
134
|
"docker", "run", "-d",
|
|
135
|
-
"-p", "8000:8000",
|
|
135
|
+
"-p", "127.0.0.1:8000:8000",
|
|
136
136
|
"--network", "bridge",
|
|
137
137
|
"--restart", "unless-stopped",
|
|
138
138
|
"configured-image",
|
|
@@ -307,7 +307,7 @@ def test_run_with_docker_expose(monkeypatch, tmp_path):
|
|
|
307
307
|
cmd, check = calls[0]
|
|
308
308
|
assert cmd == [
|
|
309
309
|
"docker", "run", "-d",
|
|
310
|
-
"-p", "9000:9000",
|
|
310
|
+
"-p", "127.0.0.1:9000:9000",
|
|
311
311
|
"--restart", "unless-stopped",
|
|
312
312
|
"demo",
|
|
313
313
|
]
|
|
@@ -137,6 +137,68 @@ def test_build_tar_gz_bytes_excludes_common_junk(tmp_path: Path):
|
|
|
137
137
|
assert ".DS_Store" not in paths
|
|
138
138
|
|
|
139
139
|
|
|
140
|
+
def test_build_tar_gz_bytes_excludes_secrets(tmp_path: Path, capsys):
|
|
141
|
+
# A valid function project...
|
|
142
|
+
(tmp_path / "Dockerfile").write_text("FROM scratch")
|
|
143
|
+
(tmp_path / "app").mkdir()
|
|
144
|
+
(tmp_path / "app" / "main.py").write_text("x=1\n")
|
|
145
|
+
|
|
146
|
+
# ...alongside secrets a careless developer might leave in the tree.
|
|
147
|
+
(tmp_path / ".env").write_text("SECRET=hunter2")
|
|
148
|
+
(tmp_path / ".env.local").write_text("TOKEN=abc")
|
|
149
|
+
(tmp_path / "server.key").write_text("-----BEGIN PRIVATE KEY-----")
|
|
150
|
+
(tmp_path / "tls.pem").write_text("-----BEGIN CERTIFICATE-----")
|
|
151
|
+
(tmp_path / "id_rsa").write_text("ssh-key")
|
|
152
|
+
(tmp_path / "credentials").write_text("[default]")
|
|
153
|
+
(tmp_path / ".ssh").mkdir()
|
|
154
|
+
(tmp_path / ".ssh" / "known_hosts").write_text("host")
|
|
155
|
+
|
|
156
|
+
tgz = submit.build_tar_gz_bytes(tmp_path)
|
|
157
|
+
paths = list_tar_paths(tgz)
|
|
158
|
+
|
|
159
|
+
assert "app/main.py" in paths
|
|
160
|
+
for leaked in (
|
|
161
|
+
".env", ".env.local", "server.key", "tls.pem",
|
|
162
|
+
"id_rsa", "credentials",
|
|
163
|
+
):
|
|
164
|
+
assert leaked not in paths, f"{leaked} must never be bundled"
|
|
165
|
+
assert all(not p.startswith(".ssh/") for p in paths)
|
|
166
|
+
# And the developer is told what was withheld.
|
|
167
|
+
assert "looks like a secret" in capsys.readouterr().out
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_build_tar_gz_bytes_skips_symlinks(tmp_path: Path):
|
|
171
|
+
(tmp_path / "Dockerfile").write_text("FROM scratch")
|
|
172
|
+
(tmp_path / "app").mkdir()
|
|
173
|
+
(tmp_path / "app" / "main.py").write_text("x=1\n")
|
|
174
|
+
|
|
175
|
+
# A symlink pointing at something outside the project must never be followed
|
|
176
|
+
# into the bundle.
|
|
177
|
+
outside = tmp_path.parent / "outside_secret.txt"
|
|
178
|
+
outside.write_text("do not exfiltrate")
|
|
179
|
+
link = tmp_path / "link_out.txt"
|
|
180
|
+
try:
|
|
181
|
+
link.symlink_to(outside)
|
|
182
|
+
except (OSError, NotImplementedError):
|
|
183
|
+
pytest.skip("symlinks unsupported on this platform")
|
|
184
|
+
|
|
185
|
+
tgz = submit.build_tar_gz_bytes(tmp_path)
|
|
186
|
+
paths = list_tar_paths(tgz)
|
|
187
|
+
|
|
188
|
+
assert "app/main.py" in paths
|
|
189
|
+
assert "link_out.txt" not in paths
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_looks_secret_matches_expected_names():
|
|
193
|
+
for name in (
|
|
194
|
+
".env", ".ENV", ".env.production", ".netrc", "id_ed25519",
|
|
195
|
+
"key.pem", "cert.PEM", "store.p12", "app.keystore",
|
|
196
|
+
):
|
|
197
|
+
assert submit._looks_secret(name) is True
|
|
198
|
+
for name in ("main.py", "requirements.txt", "environment.yml", "readme.md"):
|
|
199
|
+
assert submit._looks_secret(name) is False
|
|
200
|
+
|
|
201
|
+
|
|
140
202
|
def test_build_tar_gz_bytes_bad_path(tmp_path: Path):
|
|
141
203
|
bad = tmp_path / "does-not-exist"
|
|
142
204
|
with pytest.raises(SystemExit) as e:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|