Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,28 @@ agents:

Like other agent keys, a project-level `ports` list replaces the global one for that agent. The list applies to both `vp run` and `vp task` containers; note that two containers cannot publish the same host port at the same time, so a fixed host port limits you to one such container per agent.

## Session and task records

Interactive runs (`vp run`) write a row to the `sessions` table in the logging
database (`logging.db_path`, default `~/.config/vibepod/logs.db`, viewable in
the Datasette UI via `vp logs start`). Background tasks (`vp task create`)
write a row to the `tasks` table in `tasks.db` in the config directory;
inspect it with `vp task list --json` or `vp task status <id> --json`.

Besides the full image reference, each record tracks image provenance for
traceability and auditing:

| Column | Content |
|---|---|
| `image_tag` | The tag (or digest) portion of the image reference, e.g. `0.18.0` |
| `image_hash` | The image ID reported by the container engine (`sha256:…`) |
| `agent_version` | The agent's own version, read from the image label `vibepod.agent.version` (falling back to `org.opencontainers.image.version`) |

All three columns are best-effort and nullable: an untagged reference stores
`image_tag` as `NULL`, and images without version labels store `agent_version`
as `NULL`. Databases created by older VibePod versions are migrated
automatically on first use.

## The built-in proxy

VibePod starts a `vibepod-proxy` container alongside every agent. It acts as an HTTP(S) MITM proxy and logs all outbound requests to a SQLite database viewable in the Datasette UI (`vp logs start`).
Expand Down
11 changes: 11 additions & 0 deletions src/vibepod/commands/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from vibepod.core.herdr import (
report_pane_metadata as _report_herdr_metadata,
)
from vibepod.core.image_metadata import ImageMetadata, collect_image_metadata
from vibepod.core.launch import (
agent_extra_volumes as _agent_extra_volumes,
)
Expand Down Expand Up @@ -668,6 +669,13 @@ def run(
Path(str(log_cfg.get("db_path", "~/.config/vibepod/logs.db"))).expanduser().resolve()
)

# Collecting metadata inspects the image via the engine API — skip the
# roundtrip entirely when nothing will be persisted.
image_meta = (
collect_image_metadata(container, image)
if log_enabled
else ImageMetadata(image_tag=None, image_hash=None, agent_version=None)
)
logger = SessionLogger(log_db_path, enabled=log_enabled)
logger.open_session(
agent=selected_agent,
Expand All @@ -676,6 +684,9 @@ def run(
container_id=container.id,
container_name=container.name,
vibepod_version=__version__,
image_tag=image_meta.image_tag,
image_hash=image_meta.image_hash,
agent_version=image_meta.agent_version,
)

exit_reason = "normal"
Expand Down
5 changes: 5 additions & 0 deletions src/vibepod/commands/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from vibepod.core.config import get_config, get_config_root
from vibepod.core.docker import DockerClientError, DockerManager, _is_latest_tag
from vibepod.core.herdr import PANE_LABEL, apply_herdr_if_enabled
from vibepod.core.image_metadata import collect_image_metadata
from vibepod.core.launch import (
agent_extra_volumes,
agent_init_commands,
Expand Down Expand Up @@ -745,6 +746,7 @@ def task_create(
state = {}
initial_status = TASK_STATUS_RUNNING if container.status == "running" else TASK_STATUS_STARTING

image_meta = collect_image_metadata(container, image)
store = _task_store()
try:
record = store.create(
Expand All @@ -754,6 +756,9 @@ def task_create(
container_id=container.id,
container_name=container.name,
image=image,
image_tag=image_meta.image_tag,
image_hash=image_meta.image_hash,
agent_version=image_meta.agent_version,
vibepod_version=__version__,
status=initial_status,
started_at=_state_timestamp(state, "StartedAt"),
Expand Down
76 changes: 76 additions & 0 deletions src/vibepod/core/image_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Best-effort collection of container image metadata for sqlite records."""

from __future__ import annotations

import re
from dataclasses import dataclass
from typing import Final

from vibepod.core.docker import _parse_image_name

AGENT_VERSION_LABELS: Final = (
"vibepod.agent.version",
"org.opencontainers.image.version",
)

# A bare engine image ID (possibly truncated) used directly as the image
# reference — its colon is not a repository/tag separator.
_BARE_IMAGE_ID: Final = re.compile(r"sha256:[0-9a-fA-F]{6,64}")


@dataclass(frozen=True)
class ImageMetadata:
"""Image provenance persisted alongside session and task rows."""

image_tag: str | None
image_hash: str | None
agent_version: str | None


def collect_image_metadata(container: object, image: str) -> ImageMetadata:
"""Extract tag, hash, and agent version for the image behind *container*.

Everything is best-effort: agent images without version labels, stub
containers in tests, or a daemon that dropped away mid-inspect must never
break the launch — missing pieces simply become ``None``.
"""
if _BARE_IMAGE_ID.fullmatch(image):
image_tag = None
else:
_, image_tag = _parse_image_name(image)

image_hash: str | None = None
agent_version: str | None = None
# docker-py's Container.image and Image.labels are properties backed by API
# calls and raw inspect payloads — each access can raise (KeyError included,
# which getattr defaults do not swallow), so every read stays guarded.
try:
image_obj = getattr(container, "image", None)
except Exception:
image_obj = None
if image_obj is not None:
try:
image_id = getattr(image_obj, "id", None)
except Exception:
image_id = None
if isinstance(image_id, str) and image_id:
image_hash = image_id
try:
labels = getattr(image_obj, "labels", None)
except Exception:
labels = None
if isinstance(labels, dict):
for label in AGENT_VERSION_LABELS:
value = labels.get(label)
if isinstance(value, str) and value:
agent_version = value
break

if image_hash is None:
attrs = getattr(container, "attrs", None)
if isinstance(attrs, dict):
attr_image = attrs.get("Image")
if isinstance(attr_image, str) and attr_image:
image_hash = attr_image

return ImageMetadata(image_tag=image_tag, image_hash=image_hash, agent_version=agent_version)
25 changes: 22 additions & 3 deletions src/vibepod/core/session_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Final
from uuid import uuid4

from vibepod.core.sqlite_migrations import add_missing_columns

_SCHEMA = """\
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
agent TEXT NOT NULL,
image TEXT NOT NULL,
image_tag TEXT,
image_hash TEXT,
agent_version TEXT,
workspace TEXT NOT NULL,
container_id TEXT NOT NULL,
container_name TEXT NOT NULL,
Expand All @@ -33,6 +39,12 @@
CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at);
"""

_MIGRATION_COLUMNS: Final[dict[str, str]] = {
"image_tag": "TEXT",
"image_hash": "TEXT",
"agent_version": "TEXT",
}


class SessionLogger:
"""SQLite logger that captures user-submitted messages.
Expand Down Expand Up @@ -68,6 +80,9 @@ def open_session(
container_id: str,
container_name: str,
vibepod_version: str,
image_tag: str | None = None,
image_hash: str | None = None,
agent_version: str | None = None,
) -> str | None:
"""Create the session row. Returns the session id, or ``None`` when disabled."""
if not self._enabled:
Expand All @@ -79,19 +94,23 @@ def open_session(
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.executescript(_SCHEMA)
add_missing_columns(self._conn, "sessions", _MIGRATION_COLUMNS)

self._session_id = uuid4().hex
now = datetime.now(timezone.utc).isoformat()

self._conn.execute(
"INSERT INTO sessions "
"(id, agent, image, workspace, container_id, container_name, "
"started_at, vibepod_version) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
"(id, agent, image, image_tag, image_hash, agent_version, "
"workspace, container_id, container_name, started_at, vibepod_version) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
self._session_id,
agent,
image,
image_tag,
image_hash,
agent_version,
workspace,
container_id,
container_name,
Expand Down
32 changes: 32 additions & 0 deletions src/vibepod/core/sqlite_migrations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Shared ALTER-based sqlite column migrations."""

from __future__ import annotations

import sqlite3


def add_missing_columns(
conn: sqlite3.Connection,
table: str,
columns: dict[str, str],
) -> bool:
"""Add each column in *columns* missing from *table*.

Returns ``True`` when the table changed. A concurrently launching process
can add a column between the PRAGMA read and the ALTER — that
duplicate-column failure is tolerated (the column exists either way, and
callers' backfills are idempotent); any other ``OperationalError``
propagates.
"""
existing = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
changed = False
for column, definition in columns.items():
if column in existing:
continue
try:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
Comment on lines +21 to +27

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Validate all interpolated schema inputs.

table, column, and definition are inserted directly into SQL. Current callers pass constants, but this public helper accepts arbitrary strings; a future caller could introduce SQL injection or break migrations with malformed identifiers. Parameter placeholders cannot bind identifiers, so validate/allow-list names and type definitions, then quote identifiers before constructing these statements.

🧰 Tools
🪛 OpenGrep (1.26.0)

[ERROR] 19-19: SQL query built via f-string passed to execute()/executemany(). Use parameterized queries with placeholders instead.

(coderabbit.sql-injection.python-fstring-execute)


[ERROR] 25-25: SQL query built via f-string passed to execute()/executemany(). Use parameterized queries with placeholders instead.

(coderabbit.sql-injection.python-fstring-execute)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vibepod/core/sqlite_migrations.py` around lines 19 - 25, Update the
migration helper around the PRAGMA and ALTER TABLE statements to validate or
allow-list the interpolated table, column, and definition inputs, rejecting
malformed or unsupported values. Since identifiers cannot use parameter
placeholders, quote validated table and column identifiers safely before
constructing SQL, and only permit approved schema type definitions.

Source: Linters/SAST tools

except sqlite3.OperationalError as exc:
if "duplicate column" not in str(exc).lower():
raise
changed = True
return changed
40 changes: 30 additions & 10 deletions src/vibepod/core/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from typing import Any, Final
from uuid import uuid4

from vibepod.core.sqlite_migrations import add_missing_columns

TASK_STATUS_QUEUED: Final = "queued"
TASK_STATUS_STARTING: Final = "starting"
TASK_STATUS_RUNNING: Final = "running"
Expand All @@ -28,6 +30,9 @@
container_id TEXT NOT NULL,
container_name TEXT NOT NULL,
image TEXT NOT NULL,
image_tag TEXT,
image_hash TEXT,
agent_version TEXT,
vibepod_version TEXT NOT NULL,
created_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'running',
Expand All @@ -47,6 +52,9 @@
"started_at": "TEXT",
"finished_at": "TEXT",
"updated_at": "TEXT",
"image_tag": "TEXT",
"image_hash": "TEXT",
"agent_version": "TEXT",
}


Expand All @@ -63,6 +71,9 @@ class TaskRecord:
container_id: str
container_name: str
image: str
image_tag: str | None
image_hash: str | None
agent_version: str | None
vibepod_version: str
created_at: str
status: str
Expand All @@ -81,6 +92,9 @@ def from_row(cls, row: sqlite3.Row) -> TaskRecord:
container_id=row["container_id"],
container_name=row["container_name"],
image=row["image"],
image_tag=row["image_tag"],
image_hash=row["image_hash"],
agent_version=row["agent_version"],
vibepod_version=row["vibepod_version"],
created_at=row["created_at"],
status=row["status"],
Expand All @@ -99,6 +113,9 @@ def as_dict(self) -> dict[str, Any]:
"container_id": self.container_id,
"container_name": self.container_name,
"image": self.image,
"image_tag": self.image_tag,
"image_hash": self.image_hash,
"agent_version": self.agent_version,
"vibepod_version": self.vibepod_version,
"created_at": self.created_at,
"status": self.status,
Expand All @@ -125,13 +142,7 @@ def _connect(self) -> sqlite3.Connection:
return conn

def _migrate_schema(self, conn: sqlite3.Connection) -> None:
existing = {row["name"] for row in conn.execute("PRAGMA table_info(tasks)").fetchall()}
migrated = False
for column, definition in _MIGRATION_COLUMNS.items():
if column not in existing:
conn.execute(f"ALTER TABLE tasks ADD COLUMN {column} {definition}")
migrated = True
if migrated:
if add_missing_columns(conn, "tasks", _MIGRATION_COLUMNS):
conn.execute(
"UPDATE tasks SET updated_at = created_at "
"WHERE updated_at IS NULL OR updated_at = ''",
Expand All @@ -146,6 +157,9 @@ def create(
container_id: str,
container_name: str,
image: str,
image_tag: str | None = None,
image_hash: str | None = None,
agent_version: str | None = None,
vibepod_version: str,
status: str = TASK_STATUS_RUNNING,
started_at: str | None = None,
Expand All @@ -156,9 +170,9 @@ def create(
conn.execute(
"INSERT INTO tasks "
"(id, agent, prompt, workspace, container_id, container_name, "
"image, vibepod_version, created_at, status, exit_code, started_at, "
"finished_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"image, image_tag, image_hash, agent_version, vibepod_version, "
"created_at, status, exit_code, started_at, finished_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
task_id,
agent,
Expand All @@ -167,6 +181,9 @@ def create(
container_id,
container_name,
image,
image_tag,
image_hash,
agent_version,
vibepod_version,
created_at,
status,
Expand All @@ -184,6 +201,9 @@ def create(
container_id=container_id,
container_name=container_name,
image=image,
image_tag=image_tag,
image_hash=image_hash,
agent_version=agent_version,
vibepod_version=vibepod_version,
created_at=created_at,
status=status,
Expand Down
Loading
Loading