Skip to main content

Latchkey integration plugin for mngr - manages a shared latchkey gateway and per-agent permissions

Project description

mngr-latchkey

Latchkey gateway management for mngr.

This package owns the lifecycle of a single shared latchkey gateway subprocess and the per-agent state that points the gateway at each agent's own permissions file. It ships both as a Python library and as a mngr CLI plugin that registers the mngr latchkey command group.

CLI

Once imbue-mngr-latchkey is installed, mngr discovers the plugin via the standard entry-point mechanism and exposes:

mngr latchkey forward            # long-running supervisor: gateway + reverse tunnels
mngr latchkey create-agent-env   # emit LATCHKEY_* env vars + opaque permissions handle as JSON
mngr latchkey link-permissions   # swing the opaque handle's symlink to the canonical host path
mngr latchkey admin-jwt          # mint a wildcard permissions-override JWT for the gateway
mngr latchkey gateway-info       # print the running gateway's URL + listen password as JSON

mngr latchkey forward spawns the shared gateway eagerly on startup and stops it on SIGINT/SIGTERM (coupled lifetime). Any in-flight agents lose their gateway endpoint until the next mngr latchkey forward is started; the per-host permissions files survive across restarts.

Wiring a new agent using the CLI interface

# In one terminal, leave the supervisor running for the lifetime of the agents.
export MNGR_LATCHKEY_DIRECTORY=~/.minds/latchkey
mngr latchkey forward

# In another terminal, per host:
export MNGR_LATCHKEY_DIRECTORY=~/.minds/latchkey
mngr latchkey create-agent-env > /tmp/lk.json
OPAQUE_PATH=$(jq -r .opaque_permissions_path /tmp/lk.json)
HOST_ENV_ARGS=$(jq -r '.env | to_entries[] | "--host-env \(.key)=\(.value)"' /tmp/lk.json)

# Substitute your preferred mngr create invocation here. The latchkey
# env is passed via --host-env so every agent on the new host inherits
# the same gateway wiring.
CREATED=$(mngr create my-template $HOST_ENV_ARGS --format json)
HOST_ID=$(echo "$CREATED" | jq -r .host_id)
AGENT_ID=$(echo "$CREATED" | jq -r .agent_id)

# Finalize the opaque permissions handle: swing its symlink to the
# canonical host-keyed permissions path.
mngr latchkey link-permissions --host-id "$HOST_ID" --opaque-path "$OPAQUE_PATH"

# Register this agent for the host so it can reach the Minds API proxy.
# The baseline rule rejects every ``/minds-api-proxy/api/v1/agents/<id>/...``
# request whose ``<id>`` is not in the host's allowed-agent enum, so
# every minds agent that wants to call the Minds API must be registered
# here. Idempotent: re-running for an already-registered agent is a no-op.
mngr latchkey register-agent --host-id "$HOST_ID" --agent-id "$AGENT_ID"

Settings

[plugins.latchkey]
directory = "~/.mngr/latchkey"   # default
latchkey_binary = "latchkey"     # default; resolved via PATH

Both fields are overridable via the matching env vars (MNGR_LATCHKEY_DIRECTORY, MNGR_LATCHKEY_BINARY) and per-invocation CLI flags (--latchkey-directory, --latchkey-binary). Precedence is CLI flag > env var > settings.toml > built-in default.

Embedding

Embedders (such as the minds desktop client) typically want a single detached mngr latchkey forward supervisor that survives embedder restarts and adopts the existing one instead of double-spawning. The :class:LatchkeyForwardSupervisor does exactly that:

from imbue.mngr_latchkey.forward_supervisor import LatchkeyForwardSupervisor

supervisor = LatchkeyForwardSupervisor(
    mngr_binary="/path/to/mngr",          # default: ``mngr`` on PATH
    latchkey_binary="/path/to/latchkey",  # default: ``latchkey`` on PATH
    latchkey_directory=root_dir,
)
supervisor.ensure_running()  # idempotent; spawns or adopts as needed
# ... do whatever the embedder does ...
# Optional: ``supervisor.stop()`` to terminate the detached process and
# tear down the gateway. Omitting this leaves the supervisor running
# detached, which is what minds does so the gateway survives a
# desktop-client restart.

Python API

Every CLI subcommand is a thin wrapper around the library; the library remains importable for embedders such as the minds desktop client.

from imbue.mngr_latchkey.core import Latchkey
from imbue.mngr_latchkey.agent_setup import (
    prepare_agent_latchkey,
    finalize_host_permissions,
)
from imbue.mngr_latchkey.discovery import (
    LatchkeyDiscoveryHandler,
    LatchkeyDestructionHandler,
)
from imbue.mngr_forward.ssh_tunnel import SSHTunnelManager

latchkey = Latchkey(
    latchkey_binary="/path/to/latchkey",  # default: "latchkey" on PATH
    latchkey_directory=root_dir,
)
latchkey.initialize()

# (a) Pre-create env vars + opaque permissions handle for a new host.
setup = prepare_agent_latchkey(latchkey, is_tunneled=True)
# setup.env: LATCHKEY_GATEWAY[_SECONDARY,_PASSWORD,_PERMISSIONS_OVERRIDE,_DISABLE_COUNTING]
#   LATCHKEY_GATEWAY_SECONDARY (tunneled mode only) is the agent's URL for the
#   per-VPS gateway: http://127.0.0.1:<INNER_PORT>
# setup.opaque_permissions_path: pass to finalize_host_permissions later

# ... mngr create returns the canonical host id ...

# (b) Point the opaque handle at the canonical host permissions path.
finalize_host_permissions(latchkey, setup.opaque_permissions_path, host_id)
# Raises LatchkeyStoreError on failure -- callers decide whether to abort
# or just surface a warning.

# (c) Plug the discovery and destruction handlers into your agent
# discovery stream so reverse tunnels are opened on discovery and
# closed on destruction.
tunnel_manager = SSHTunnelManager()
tunnel_manager.start_reverse_tunnel_health_check()
on_discovered = LatchkeyDiscoveryHandler(
    latchkey=latchkey, tunnel_manager=tunnel_manager, concurrency_group=cg
)
on_destroyed = LatchkeyDestructionHandler(tunnel_manager=tunnel_manager)

The latchkey_directory is used both as the upstream LATCHKEY_DIRECTORY for spawned latchkey subprocesses and as the root of this package's own metadata subdirectory (<latchkey_directory>/mngr_latchkey/, accessible via Latchkey.plugin_data_dir).

Permissions config

The package owns the latchkey_permissions.json schema (a subset of detent's rule format). Per-host edits go through the gateway's bundled permissions extension (see below); only the deny-all default, the admin file, and the per-agent opaque baseline are written directly via imbue.mngr_latchkey.store.save_permissions.

Gateway HTTP extensions

mngr latchkey forward drops three .mjs extensions into <latchkey-directory>/extensions/. All expose plain HTTP endpoints on the gateway's listen port and authenticate the caller via two headers:

  • X-Latchkey-Gateway-Password: <password> -- the gateway listen password from mngr latchkey gateway-info.
  • X-Latchkey-Gateway-Permissions-Override: <jwt> -- a JWT minted for the permissions file you want the gateway to evaluate the request against. For full access to both extensions, use the JWT from mngr latchkey admin-jwt.

A shell client would typically wire these up once:

ADMIN_JWT=$(mngr latchkey admin-jwt)
eval "$(mngr latchkey gateway-info | jq -r '@text "GATEWAY_URL=\(.url); GATEWAY_PASSWORD=\(.password)"')"
auth=(-H "X-Latchkey-Gateway-Password: $GATEWAY_PASSWORD" -H "X-Latchkey-Gateway-Permissions-Override: $ADMIN_JWT")

permission-requests extension

A pending-permission queue. Agents submit a request when they hit a blocked service; UIs (the minds desktop client, your own front-end) consume the stream and approve/delete on resolution.

  • POST /permission-requests with body {"agent_id": "...", "rationale": "...", "type": "...", "payload": {...}}. Two type values are accepted:

    • "predefined" -- detent scope/permission grant, with payload {"scope": "...", "permissions": ["...", ...]}. The scope must be one named in the bundled services.json catalog, and each permission must be either one the catalog lists for that scope or the catch-all any.
    • "file-sharing" -- single-file access through the minds-api-proxy extension, with payload {"path": "<absolute-path>"}. The path must be absolute and free of .. segments.

    The extension generates a request_id server-side, stores the caller-supplied fields plus the target permissions.json (taken from the extension context) and a precomputed effect ({rules?, schemas?}) that an approval would splice into target, and returns the full persisted record. Available to agents.

  • GET /permission-requests returns the current queue as newline-delimited JSON. Each line carries the full persisted shape. Add ?follow=true to keep the connection open and stream every newly-POSTed request as it arrives. Available to the admin.

  • POST /permission-requests/approve/<request_id> approves the named request: the extension reads it, splices its effect into its target permissions.json (creating the file if missing, merging rules by scope key and schemas by name), then removes the pending request file. Returns 200 with {request_id, target, applied} where applied is the freshly-rewritten permissions file. Available to the admin.

  • DELETE /permission-requests/<request_id> removes a single pending request without applying its effect. UIs call this on deny so a fresh ?follow=true consumer never sees the resolved request again. Available to the admin.

Pending requests are stored as one JSON file per request under <latchkey-directory>/permission_requests/v2/. The v2 segment is the on-disk schema version; future shape changes get a new directory rather than trying to migrate files in place.

minds-api-proxy extension

Transparent HTTP reverse proxy from the gateway to an embedder-supplied "Minds API" base URL.

  • ANY /minds-api-proxy forwards to <minds-api>/.
  • ANY /minds-api-proxy/<rest>... forwards to <minds-api>/<rest>..., preserving the inbound method, query string, headers (minus hop-by-hop entries and the gateway-internal password / permissions-override headers), and body. The upstream response status, headers, and body stream straight back.

The upstream base URL is read from the LATCHKEY_EXTENSION_MINDS_API_URL env var on every request. If the var is unset/empty/unparseable the proxy responds 503 with a JSON error body. There is no in-process cache to invalidate: an embedder that needs to repoint the proxy at a new upstream simply respawns the gateway (or the mngr latchkey forward supervisor that owns it) with a fresh value for the env var.

The proxy authenticates to the upstream Minds API on behalf of the agent. When LATCHKEY_EXTENSION_MINDS_API_KEY is set, the proxy overwrites the inbound Authorization header with Bearer <LATCHKEY_EXTENSION_MINDS_API_KEY> before forwarding. Agents therefore never see the key, and an agent that tries to spoof an Authorization header has its value dropped on the floor. When the env var is unset, the inbound Authorization value is forwarded unchanged (useful for tests / local fixtures that do not bother stubbing the key; the upstream will simply 401 the request).

Other than the Authorization overwrite, the extension performs no authentication of its own beyond the gateway's normal permission check (against the synthetic latchkey-self.invalid URL). Restricting which paths an agent can reach through the proxy is therefore a job for the agent's latchkey_permissions.json.

permissions extension

Reads and edits a detent permissions file at a caller-supplied path. The gateway is launched with the environment variable LATCHKEY_EXTENSION_PERMISSIONS_ROOT pointing at this package's data directory; any path query parameter that resolves outside that root is rejected with HTTP 403.

  • GET /permissions?path=<file> returns the full permissions file.
  • GET /permissions/available returns the full permission catalog as a JSON object keyed by raw service name. Each value is an array of scope entries (a single service may expose more than one scope), each with the shape {"scope": "<schema_name>", "display_name": "...", "description": "...", "permissions": [{"name": "<schema_name>", "description": "..."}, ...]}. The scope-level description and each permission's description carry detent's per-schema $comment summaries (both optional).
  • GET /permissions/available/<service_name> returns the permission catalog entries for <service_name> (e.g. slack, google-gmail) as an array, using the same value shape, or 404 if the service is unknown. The catch-all any permission is always injected at index 0 of every scope's permissions array, so a caller can always request unrestricted access under a known scope. This endpoint is backed by a services.json file (keyed by raw service name) that ships alongside the extension; the path query parameter is not consulted.
  • GET /permissions/rules?path=<file>&rule_key=<scope> returns the rule for <scope>, or 404 if absent.
  • POST /permissions/rules?path=<file>&rule_key=<scope> with a JSON body of permission-schema names (["any"], ["slack-read-all", ...], ...) adds or replaces the rule for <scope>. Everything in the file other than the matching rule is preserved verbatim.
  • DELETE /permissions/rules?path=<file>&rule_key=<scope> removes the named rule.

The services.json catalog is generated from detent's built-in request schemas; do not edit it by hand. Regenerate it against a detent checkout with:

uv run python libs/mngr_latchkey/scripts/generate_services_json.py \
  --detent-root /path/to/detent

Display names and the service ordering are editorial metadata detent does not carry; they live as curated constants in that script.

A typical end-to-end shell flow:

# Stream pending requests as they come in.
curl -N "${auth[@]}" "$GATEWAY_URL/permission-requests?follow=true"

# Grant the agent slack-read-all on its host's permissions file.
HOST_PERMS=$MNGR_LATCHKEY_DIRECTORY/mngr_latchkey/hosts/$HOST_ID/latchkey_permissions.json
curl -X POST "${auth[@]}" \
  -H "Content-Type: application/json" -d '["slack-read-all"]' \
  "$GATEWAY_URL/permissions/rules?path=$HOST_PERMS&rule_key=slack-api"

# Clear the pending request now that it has been resolved.
curl -X DELETE "${auth[@]}" "$GATEWAY_URL/permission-requests/$REQUEST_ID"

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

imbue_mngr_latchkey-0.1.2.tar.gz (201.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

imbue_mngr_latchkey-0.1.2-py3-none-any.whl (122.1 kB view details)

Uploaded Python 3

File details

Details for the file imbue_mngr_latchkey-0.1.2.tar.gz.

File metadata

  • Download URL: imbue_mngr_latchkey-0.1.2.tar.gz
  • Upload date:
  • Size: 201.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for imbue_mngr_latchkey-0.1.2.tar.gz
Algorithm Hash digest
SHA256 4ab8c2a986bfb198cfb52bc9acf0907757bf240273b6aacfbd615b9b5cc78517
MD5 5ce2fb2763a85f055837986411082450
BLAKE2b-256 d445ee4aff7cdfd0ce59c1d99ec999d30b1e55fa59236790ffdc2e3f6412afdd

See more details on using hashes here.

Provenance

The following attestation bundles were made for imbue_mngr_latchkey-0.1.2.tar.gz:

Publisher: publish.yml on imbue-ai/mngr

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file imbue_mngr_latchkey-0.1.2-py3-none-any.whl.

File metadata

File hashes

Hashes for imbue_mngr_latchkey-0.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 88dff48a0b76b671b44477d54afcae5faf58826aacb0114d8716d32c47d76147
MD5 f10d0f34773b1625eae73d5538376536
BLAKE2b-256 64de6b7011286dee3a76753828d8a7665c9c32a2f7ae6657a8387b7073f338ac

See more details on using hashes here.

Provenance

The following attestation bundles were made for imbue_mngr_latchkey-0.1.2-py3-none-any.whl:

Publisher: publish.yml on imbue-ai/mngr

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page