Skip to content

PyRun 2.0.0

Latest

Choose a tag to compare

@hassancs91 hassancs91 released this 23 Oct 08:28

Upgrade notes (from 1.x)

  • Selected secret injection is now exact. A user script in Selected mode
    receives only the secrets attached to it on the script form. Before 2.0 it also
    received every unowned workspace secret, which is the same set All gives, so
    the setting narrowed nothing. After upgrading, open each script that uses
    Selected, attach the secrets it reads (Auto-detect from code finds them) or
    switch it to All. Plugin scripts in Selected mode (the SDK default) get
    their own plugin's secrets and the secrets attached to them; a user's unowned
    secret no longer reaches them unless it is attached (the bundled example
    plugins only use their own secrets).
  • Datastore and plugin API tokens are stored hashed. Like Unified API
    tokens, they are kept only as a SHA-256 digest; the upgrade (migration 0065)
    hashes the existing ones. Tokens keep working unchanged; the list shows the
    first 8 and last 4 characters. A database copy or dump no longer contains
    usable tokens. Rolling back to 1.x after upgrading invalidates them (create
    new ones there).
  • Each script has its own working folder. A run's current directory is now
    /app/data/workdir/<script id>/ (the id is in the script's URL) instead of the
    folder every script shared. Files a run writes there are there for the script's
    next run, other scripts no longer see them, and the folder is deleted with the
    script (a backup restore keeps the folders of the scripts it restores; the files
    are not part of backups). Files scripts wrote before 2.0 stay in
    /app/data/workdir/; move what a script still reads into its own folder, or
    read it by absolute path.
  • Container process limit. docker-compose.coolify.yml now sets
    pids_limit: 2048 (processes and threads for the whole container). Scripts
    run as root in the image and the kernel exempts root from the per-run process
    limit (RLIMIT_NPROC), so that setting can't stop a fork bomb; Settings →
    Isolation now marks it "not enforced while scripts run as root". Raise the
    limit if your scripts start many processes (several headless browsers), and set
    one yourself on other deployments (docker run --pids-limit, or compose
    pids_limit).
  • New image name, and Coolify keeps your old compose file. New releases are
    published as hasanaboulhasan/pyrun; an install that still pulls
    hasanaboulhasan/pyrunner:latest stays on the last image published under that
    name, so change the image name when you update. On Coolify, Redeploy pulls
    the image named in the compose file the resource already has: a 1.x Docker
    Compose resource keeps the 1.x compose, without pids_limit: 2048,
    RATELIMIT_TRUSTED_PROXY_DEPTH=1 (login lockout and per-IP rate limits then
    see Traefik's address for every visitor) or the pass-through for
    DATABASE_URL, PYRUNNER_DATA_DB_URL and PYRUNNER_ALLOW_PRIVATE_NETWORK.
    Replace the resource's compose with the 2.0 docker-compose.coolify.yml
    (keep your environment values) before you redeploy. A Docker Image resource
    needs the new image name and RATELIMIT_TRUSTED_PROXY_DEPTH=1 added by hand.
    The Coolify install guide's Upgrading from 1.x section has the steps.
  • An upgraded instance keeps the name "PyRunner". 1.x saved its default
    instance name in the database, and the upgrade doesn't change a saved name, so
    the header, page titles and emails still say "PyRunner". Change it under
    Settings → General → Instance name if you want "PyRun".

Added

  • Workspace Brains — immutable revisions and deduplicated text blobs,
    a file browser with visibility and freshness badges, owner/admin edits,
    revision diffs, access grants, template creation, and ZIP or public GitHub
    import/export. Existing BrainOutside 1.0 content is preserved. Backup format
    1.13.0 includes opted-in brains and their full history; token grants must be
    recreated after restore.
  • Brain read and write tools — shared REST/MCP, signed script loopback,
    plugin BrainAPI (SDK 2.7) and grant-filtered agent tools. Captures produce
    validated proposals for owner/admin review, with diffs, conflict re-review,
    idempotent approval and cost caps. The confined reader assembles cited context;
    console requests run asynchronously. Optional vector search verifies results
    against canonical notes and the caller's current tier.
  • GitHub Brain sync — HTTP Git Data exports with explicit all-tier consent,
    a reviewed initial SHA and non-force updates. Pulls create reviewable proposals;
    optional polling never approves or exports. Credentials use workspace Secrets.
    Restore pauses polling and drops transient jobs while preserving history.
  • MCP and Connect — stateless Streamable HTTP at /mcp, shared API
    handlers, permission-filtered OpenAPI, and workspace connector instructions.
    Expiring read-only URL tokens are opt-in. Supports current per-request
    protocol metadata and older clients' initialization handshake; no sessions
    or streaming worker connections.
  • Unified API registry and REST — permission-scoped, hashed API tokens;
    one schema and handler per tool-call endpoint, GET for reads, a filtered
    /api/v1/_catalog, and /api/v1/_health. Existing datastore and plugin token
    scopes retain their reach. Brain endpoints share these handlers; REST names
    use dots (brain.get-note), MCP names hyphens (brain-get-note).
  • Vectors: embedding providers, vector stores and collections — Services
    gains two cards: Embedding Providers
    (OpenAI, Voyage, Jina, Z.AI, a local Ollama, or any OpenAI-compatible
    /v1/embeddings endpoint; Test records the vector dimension) and Vector
    Stores
    : the built-in Local store (one SQLite file per collection on the
    data volume, searched exactly with numpy), seeded and set as the default, and
    Qdrant servers, where each collection becomes one Qdrant collection named
    after its workspace. A new Vectors page per workspace creates collections,
    pins the provider's model and vector dimension to each one, runs real searches
    with JSON payload filters, browses points and deletes with a typed
    confirmation.
    • Scripts use the new pyrunner_vectors helper (standard library only,
      no keys in the run): get_collection("docs") then add_texts,
      add_vectors, search, search_vector, get, count, scroll,
      delete and embed. A plugin's collections are open to that plugin's
      scripts only; every other collection to any script in its workspace. Each
      run is braked at 2 000 embedding calls, 20 MB of embedded text and 200 000
      points written or deleted, so a runaway loop can't run up a provider bill.
    • Plugins get VectorAPI (plugin SDK 2.7): provision("docs", embedding_provider=..., dimension=..., metric=...) is idempotent and
      refuses to silently change a collection's model or dimension; get,
      list, delete and the same operations as the helper. Manifests declare
      provisions.vector_collections, and the plugin doctor checks it. The SDK
      Showcase example gains a Vectors card.
    • One filter grammar on both stores — equality, $in, ranges,
      $prefix, must / must_not — with strict types, array fields that match
      on any element, and scores that are always "higher is better", so a
      collection answers the same on Local and on Qdrant.
    • Native Qdrant access stays off unless the operator ticks Share
      credentials with runs
      on the connection; then a script's
      credentials() returns the URL, API key and physical collection name for
      qdrant-client.
    • Backup format 1.13.0 carries providers and connections (upserted by name,
      credentials encrypted as stored) and Local collections with their points
      inline, up to PYRUNNER_VECTOR_BACKUP_MAX_MB (default 50) each; Qdrant
      collections are carried as settings and reattach to the server on restore.
      Removing a plugin with remove data deletes the collections it owns.
  • Cron expression schedules — a sixth run mode that takes a standard
    5-field cron expression (0 9 * * 1-5 for weekday mornings, */15 9-17 * * *
    for every quarter hour during business hours), for anything the guided
    daily/weekly/monthly modes can't say. The form validates as you type and
    previews the next three runs; the plugin SDK gets ScheduleAPI.sync(..., mode="cron", cron="..."). Like the other clock-based modes the expression
    is written in the schedule's timezone and converted to UTC for the
    scheduler (day-of-week/month fields move with a midnight crossing, and the
    daily DST resync covers cron too), so 0 9 * * 1-5 in Asia/Tokyo really
    fires at 09:00 Tokyo time. Contributed by @cmcau (#5); timezone conversion
    added on merge. Backup format 1.8.0 carries the new field.
  • Yearly schedules — a run mode for "once a year on this date": pick a
    month, day, time and timezone. The scheduler stores the next real local
    occurrence (so the right DST offset applies for that date and a schedule
    set late in December can land in the next UTC year), and 29 February runs
    only in leap years — including when the timezone conversion pushes the UTC
    date onto 28 February or 1 March. Available to plugins via
    ScheduleAPI.sync(..., mode="yearly", month=, day=, time_str=, tz=) and
    carried by backups (format 1.8.0). Contributed by @djpfs (#16).
  • Account management from the command line — python manage.py user is
    the way back in when the console can't help: no email configured for reset
    links, a login lockout, or no admin who can sign in.
    user reset-password <email> sets a new password (prompted, or --generate
    to print a random one), signs out that account's sessions and clears its
    lockout. user create <email> [--admin], user promote, user demote,
    user unlock and user list cover the rest; the last admin can't be
    demoted. In Docker:
    docker exec -it pyrunner python manage.py user reset-password you@example.com.
  • Any interval, not just the preset list — interval schedules take any
    whole number of minutes from 1 to 32767 (about 22 days) instead of the
    fixed 5/10/15/30/60/120/360/720 menu, so "every 7 minutes" or "every 90
    minutes" is just typed in. Quick-pick buttons cover the common values.
    The plugin SDK's ScheduleAPI.sync(mode="interval", interval_minutes=…)
    now validates the same range instead of accepting anything.
  • Fork a script — a Fork button on the script page duplicates it into the
    same workspace: code, description, environment, timeout, isolation and
    notification settings, tags, notification channels, secret grants and
    library attachments all come along. The copy is a plain user-owned script
    (forking a plugin's script is the supported way to customize it), starts
    with no webhook token, and its schedule is cloned paused so a duplicate
    never fires before you've looked at it. Run history stays with the
    original. You land on the copy's edit page.
  • Search runs by output — the Runs page has a search box that matches
    script names and error output (stderr), so "which run raised KeyError?"
    is one query instead of opening runs one by one. Tick Include output to
    search stdout too (it's the big column, so it's opt-in). The search
    composes with the status tabs and the script picker, and switching those
    keeps your query.
  • Duration chart on the script page — the last 30 finished runs as a
    bar chart under Statistics, coloured by outcome, with the average drawn
    across them and the slowest run called out. A script that is slowly
    getting slower is visible at a glance instead of buried in the runs
    list. Each bar links to its run. Rendered server-side, no JavaScript.

Changed

  • Py AI can say why a script failed. The console Py AI gets a read-only
    get_run_error tool: the exit code and the last 2,000 characters of stderr of
    a script's most recent failed or timed-out run, in the current workspace. The
    stored output was already masked for the run's secrets. Py AI stays limited to
    workspace owners and admins; Py AI on chat channels doesn't get this tool.
  • Large run output keeps its ending. stdout and stderr are each stored up
    to 1 MB per run (unchanged). Beyond that a run used to keep only the first
    1 MB, losing the end of the output, which is where a result line or a
    traceback usually is. It now keeps the first 200 KB and the last ~800 KB, cut
    at line breaks, with a marker in between:
    [OUTPUT TRUNCATED: N bytes cut from the middle. Kept the first … and the last … of … bytes.]
  • Rebranded to PyRun — the product is now called PyRun and lives at
    pyrun.ai. Console, emails, setup and error pages, the
    docs links, and the GitHub (hassancs91/PyRun) and Docker Hub
    (hasanaboulhasan/pyrun) names all follow. Nothing you wrote changes:
    the pyrunner_* script helpers, PYRUNNER_* environment variables,
    PyRunnerPlugin SDK class, and data paths keep their names, so existing
    scripts, plugins, and deployments keep working. Pull the new image name
    when you next update. Page titles now use the instance name from
    Settings → General instead of a hardcoded brand.

Fixed

  • manage.py migrate_db copies the whole instance. Moving from SQLite to
    PostgreSQL went through backup and restore, which is portable by design and so
    left out users, workspace members, API tokens, AI providers, channels, plugin
    records and library history: the migrated instance came up with no admin
    account and an open /setup/admin/ page. It now copies every table row for row
    with its original keys and exact timestamps, replaces whatever the target
    database held, and checks every table's row count. Data-server databases,
    vector stores and environments are left as they are. Sessions are not copied,
    so everyone signs in again.
  • One unusable database no longer fails a whole restore. A database row
    that was never provisioned (no schema, role or password in the backup) made
    the restore stop with "Cannot decrypt empty value", so no backup of that
    instance could be restored. It is now restored in error status with a warning,
    like any other database that can't be provisioned.
  • A database restart no longer loses a finished run's result. When the
    PostgreSQL server holding the core database restarted while a script ran, the
    script finished but its result could not be saved over the worker's dead
    connection: the run kept showing "running" until the stale-run check failed it
    minutes later, with its output lost. The worker now reconnects and retries the
    save for about a minute.
  • A crashed worker no longer leaves a second one running. When the worker's
    main process was killed hard (kill -9, an out-of-memory kill), its child
    processes kept running and the container's monitor started a new worker next
    to them: two clusters with twice the configured workers, and "Restart
    workers" could only stop the new one. The monitor now stops what is left of
    the old worker before it starts the new one.
  • The Logs page keeps every process's lines after the log rotates. The web
    workers and the task worker write one pyrunner.log; when it reached 10 MB
    the process that rotated it moved on to a new file while the others kept
    writing to the renamed one, so the Logs page showed only part of the log,
    later rotations deleted files still in use, and two processes rotating at once
    printed a "Logging error". Every process now follows the current file and
    only one rotates it at a time.
  • Brain AI works on PostgreSQL. Every brain feeder extraction and reader
    run failed on a PostgreSQL core database ("Extraction failed. Check the worker
    and retry."): the daily-cap check locked the brain row outside a transaction,
    which PostgreSQL refuses and SQLite ignores. It now runs in one.
  • A run that was executing when the container or its worker restarted is now
    marked failed as soon as the worker starts again, if its process is gone. It
    used to show "running" until its timeout plus five minutes had passed (65
    minutes for the default timeout). Workers you start yourself
    (manage.py pyrunner_qcluster without --reconcile-orphans, e.g. split worker
    containers) keep the deadline rule; set PYRUNNER_RECONCILE_ON_START=false if
    several all-in-one containers share one database.
  • Backup restore now rolls back database changes when a non-database error
    interrupts the replacement, preserving existing rows after failed restores.
  • SQLite transactions acquire their write lock at the start, avoiding immediate
    read-then-write failures while workers update the database.
  • Qdrant uses separate 3-second connect and 10-second read timeouts. After a
    backend failure, collections on that connection fail quickly for 30 seconds
    across workers. Services Test bypasses the pause and clears it on recovery;
    server error bodies are no longer exposed to scripts.
  • Weekly and monthly schedules survive a backup/restore — the export
    omitted weekly_days/weekly_times/monthly_days/monthly_times, so a
    restored weekly or monthly schedule came back with no days or times. (#5)
  • Dashboard "upcoming runs" shows weekly and monthly schedules — the
    widget filtered on interval/daily only, so weekly and monthly schedules
    never appeared even though they fired on time. (#5)
  • CI is green again — the workflow never passed since it was added:
    tests needed collectstatic (the manifest storage rejects unknown static
    files under the test runner's DEBUG=False), and an unpinned Ruff picked
    up 0.16's much larger default rule set. Both pinned down; no product code
    changed.
  • A worker dying mid-run now triggers the run's failure notification —
    runs the stale-run reconciler flipped to failed never went through the
    normal finalize path, so the one failure the platform most needs to report
    (your worker was killed or restarted while your script ran) was the one it
    stayed silent about. Reconciled runs, including runs lost from the queue,
    now send the script's configured notifications.
  • Bulk requirements install no longer gets stuck — the pip subprocess and
    the worker task that hosts it both had a 600 s limit, so on a slow install
    the worker killed the task first, pip kept running orphaned in the venv,
    and the queue re-delivered the same install into it. Every package task
    now gets its own timeout above pip's cap, pip runs in its own process group
    (a timeout kills its build helpers too), with stdin closed and
    PIP_NO_INPUT set so it can never wait on a prompt; the worker retry
    window accounts for the longest pip task, and stuck package operations
    are reconciled by the worker heartbeat instead of only when someone opens
    the packages page.
  • A failed environment create no longer poisons its name — a python -m venv that failed halfway (typically Debian's /usr/bin/python3 without
    python3-venv) left a folder behind that blocked the name forever with
    "Path already exists" and no way to clean up. Partial venvs are removed on
    failure, an existing folder no environment references is reclaimed, and the
    interpreter picker only lists Pythons that can actually bootstrap pip.
  • A plugin's min_pyrunner is now enforced — the manifest field was
    documented but never checked, so a plugin built for a newer PyRun
    uploaded and activated on an older one and failed later in confusing ways.
    Upload and activation now refuse it with the required version.
  • A stray plugin folder no longer blocks uploads of that slug — a folder
    under the plugins directory with no plugin record (a delete that crashed
    halfway, or files shipped inside an image) made every upload of the same
    slug fail with "already exists" and nothing in the UI could remove it. It
    is now moved aside (never deleted) and the upload proceeds; a folder loaded
    in dev mode is left alone with a clear message.
  • pyrunner_db.connect() tells the truth about psycopg — the error
    claimed the driver "ships with PyRun's runtime"; it doesn't ship inside
    script environments. The message now points at Environments → Packages.
  • Changing the Django admin URL slug says it needs a restart — routes are
    built at startup, so the old slug stayed live and the new one 404'd until
    the next restart, with nothing telling you so. Saving a changed slug now
    shows exactly that.
  • run-local-postgres.ps1 -Fresh really wipes — docker compose down -v
    only removes volumes it can attribute to its own project, so a stack that
    had been brought up under another project name kept its month-old Postgres
    data and the "fresh" boot silently reused it. The launcher now removes the
    three named volumes explicitly and refuses to continue if any survive.

Improved

  • Console icons. The sidebar, section cards and empty states use Lucide
    icons (ISC, vendored in core/icons/lucide/, rendered inline by the
    {% icon %} template tag: no CDN or JavaScript). Plugin nav icons are unchanged.
  • Scripts list. 25 scripts per page, a search on name and description, and
    status, tag, owner, search and page in the URL so they combine and can be shared.
  • Brain grants. Plugin and agent grants are picked from a list (installed
    plugins by name; "Py AI (console)" or Py AI on a channel) instead of typing an
    identifier; existing grants show the friendly name. The Py AI page lists the
    brains it can read, with tier and a link to the grants.
  • brand_tracker 1.1.1. The public report no longer shows its template
    comment as text at the top of the page.
  • Readable failures. Secret-provider, vector-store and Telegram network
    errors are one line naming the host, port and cause ("doesn't resolve",
    "connection refused", "timed out after 10 s"); the raw text is under
    Details. Telegram's bare "Unauthorized" says the bot token was rejected, and
    a network error never echoes the bot token from the request URL. A missing
    bucket is named instead of boto's "HeadBucket … 404".
  • Forms and results. Services Test results render under the clicked button;
    a refused storage save re-opens the form with its values; settings saves stay
    on their tab and name the field in errors; add forms ask for the key instead of
    "Leave blank to keep current".
  • Runs, scripts and schedules. A truncation badge, a hint when a configured
    memory/CPU/file-size limit stopped a run, numbered forks, the next run in the
    schedule's own timezone, and an error banner when a script save fails.
  • Data pages. pip/setuptools can't be uninstalled from an environment, the
    Python picker lists each interpreter once, plugin-owned stores say so, the
    database explorer counts small never-analysed tables exactly, the vector page
    shows "Store unreachable" during an outage, and new collections preselect the
    last tested embedding provider.
  • Tokens, Connect, Brains. The tokens list shows bound brains, expired
    tokens and inactive plugins (cards on phones); Connect has Copy buttons;
    deleting a token deletes its brain grants; brain settings show the search
    index state and today's spend; Ask brain refuses a cap-blocked query at once;
    an invalid proposal still shows what it would write.
  • Py AI and channels. Py AI talks about the current workspace, its model hint
    follows the active provider, and answers sent to Telegram are plain text.
  • Admin pages. Logs open a message in full, keep filters in pager links and
    refresh only the table; Members lists the Owner first and marks "(you)";
    manage.py recaptcha status|off switches reCAPTCHA off from the shell; the
    header resource meters are superuser-only; the header Docs link opens the
    GitHub README.
  • Every width fits. The header no longer runs off the screen at the tablet
    and small-laptop breakpoints (640, 768 and 1024 px): the version label and
    What's new icon appear from 768 px and the Discord, Docs and GitHub icons from
    1280 px. On phones the Workspaces list puts each row's actions under the name,
    and a pending invite's details wrap instead of running under its buttons.
  • Accessibility. The Services card toggles have accessible names, and the
    header and sidebar navigation are labelled landmarks.
  • One console layout. Every console page shares one shell and page header
    (title, breadcrumb, actions) with a skip link. Create and edit forms sit at
    form width with their help in a side panel and Cancel next to the primary
    button at the bottom right; detail pages use the full width. Brains get
    segmented tabs, the Services page opens only the first unconfigured card and
    has a sticky section index, and Environments and Users are tables (Users can
    be filtered and shows each user's workspaces). On phones, lists drop their
    secondary columns instead of scrolling sideways, recent runs show status and
    time under the script name, and selects leave room for their arrow. A signed-in
    member who opens an admin-only page gets a 403 inside the console ("Only
    instance admins can open this page.") instead of a redirect. The developer
    links moved from the sidebar footer to an About PyRun card under
    Settings → System. Plugin pages keep their own layout for now.

Security

  • The Brain reader can no longer read files outside its snapshot. Its file
    guard did not treat a leading ~ the way the Claude CLI does, so a prompt that
    reached brain.assemble-context could make the reader open files in the
    server's home directory or /proc. ~ paths and escaping Glob/Grep patterns
    are refused, and every AI agent PyRun starts (Brain, Py AI, provider tests)
    now runs without the instance's own secrets (SECRET_KEY, ENCRYPTION_KEY,
    DATABASE_URL, PYRUNNER_DATA_DB_URL, REDIS_URL, RESEND_API_KEY and
    PYRUNNER_RUN_ENV_DENYLIST) in its environment. Py AI gets no built-in tools,
    only its own read-only ones.
  • A user removed from every workspace no longer sees across workspaces. Such
    a user could still open the console, create and run a script with no
    workspace, and that run received every workspace's secrets. The console and
    plugin pages now show "You're not in any workspace" (403) to them; a run with
    no workspace gets only unassigned secrets; and the internal data-store,
    database, vector and channel lookups no longer treat "no workspace" as the
    default workspace. Superusers and upgraded single-workspace instances are
    unaffected (the tenancy backfill assigned every existing row).
  • Outbound fetches are pinned to the address that was checked. Brain URL
    imports, GitHub import and sync, and plugin answer completion now connect
    only to the IP address the SSRF guard approved, on every redirect, so a
    DNS-rebinding name cannot switch to a private address after the check.
  • Console escaping and page hygiene. The console's HTML-escaping helpers
    now escape quotes too; developer notes and internal ticket numbers are no
    longer sent in page source; anonymous pages no longer show the version;
    the pinned Monaco loader and Chart.js are loaded with Subresource Integrity;
    secret values are masked until you click Show. docker-compose.postgres.yml
    now states that its default database passwords must be changed, and the
    Docker build context excludes every private plugin folder and node_modules.
  • Brand Tracker 1.1.2 escapes scraped titles, links and snippets in its email
    digest and keeps its public report share in the workspace its data lives in.
  • External secret providers can no longer reach private hosts. The custom,
    Vault and Infisical secret backends fetched whatever endpoint their config
    named with no SSRF check, so one pointed at the cloud-metadata address
    (169.254.169.254) returned the instance's credentials as "secret values".
    They now apply the same PYRUNNER_ALLOW_PRIVATE_NETWORK guard as storage,
    webhooks and vector stores; the metadata range stays blocked either way.
  • Delete and archive confirmations escape the item's name. The
    confirmation dialogs on scripts, tags, data stores, entries, secrets,
    libraries, packages, channels and invites built their text as a JavaScript
    string with HTML escaping only, so a quote in a name broke the dialog (and a
    crafted name could run in it). Those names are now JavaScript-escaped.
  • A gzip backup can't exhaust memory on restore. Settings → Backup unpacked
    an uploaded .json.gz completely before checking it, so a few hundred KB of
    gzipped zeros grew the web process by hundreds of MB (and the 100 MB upload
    cap allowed far more). The upload is now unpacked in chunks and refused past
    PYRUNNER_BACKUP_MAX_EXPANDED_MB (default 256 MB, above the 100 MB upload
    cap, so any backup that loads as plain JSON still loads gzipped); restore a
    larger backup on the server with python manage.py restore_backup.
  • A malformed chat webhook payload returns 400, not a 500. An inbound
    Telegram update with the right secret but an unexpected shape raised an error
    inside the public /channels/<token>/ endpoint; it is now ignored or answered
    with a 400.
  • A run always uses its script's workspace. A run started by plugin code
    that set no workspace (the bundled Sales Dashboard's Run collector now)
    ran its script with the default workspace's data stores and every
    workspace's secrets. Such runs now take their script's workspace.
  • Docker builds exclude every root .env.* variant, including local test
    credentials, in addition to .env and the existing private artifact rules.
  • The login lockout now actually locks — the limit of 5 failed sign-ins
    (15-minute cool-off) recorded every failure against no account, so the
    correct password still signed in after any number of wrong guesses and
    password guessing was never stopped. Failures are now counted against the
    email being tried, per address, and a locked account refuses even the
    correct password until the cool-off passes. Locked yourself out? Run
    python manage.py user unlock <email> on the server.
  • One SSRF guard, with an operator opt-in for private networks — every
    URL you type into the console that PyRun then calls out to (S3 endpoints,
    notification webhooks, and the vector stores and Brain imports that follow)
    now goes through a single guard, core/netguard.py. Behaviour is unchanged
    by default: hosts that resolve to loopback, private or link-local addresses
    are rejected. New: set PYRUNNER_ALLOW_PRIVATE_NETWORK=true to reach a
    Qdrant, MinIO, ntfy or Ollama container on the same Docker network (or
    localhost in development). The cloud-metadata range (169.254.0.0/16,
    fe80::/10, fd00:ec2::254) and the unspecified addresses stay blocked
    whatever the flag says, and rejected URLs now say which flag would allow
    them.