chore(wip): backup MCP hardening and A1 work

This commit is contained in:
Chris Christiansen 2026-09-08 01:09:31 +00:00
parent 630440b417
commit 0faa8a284f
17 changed files with 1055 additions and 39 deletions

View File

@ -1,13 +1,24 @@
# Definitive CI/CD pipeline for the OPAX-MCP service.
# Uses YAML anchors for readable and reusable steps.
# --- Reusable Step Definitions (YAML Anchors) ---
x-reusable-steps:
# Anchor for Tyr Policy Check
- &tyr-policy-check
name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
# --- Pipeline Steps ---
steps:
# 0. Clone the specific branch from the repository
- name: 'gcr.io/cloud-builders/git'
id: 'Clone Repository'
args:
- 'clone'
- '--branch'
- '${_BRANCH_NAME}'
- '--single-branch'
- 'http://34.59.131.162:3000/chris/osvauco.git'
- '.' # Clone into the current directory (/workspace)
waitFor: ['-'] # Run first
# 1. Run Tyr Policy Check
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
id: 'Tyr Policy Check'
entrypoint: 'bash'
waitFor: ['Clone Repository']
args:
- '-c'
- |
@ -17,17 +28,18 @@ x-reusable-steps:
# A non-zero exit code here will fail the build.
echo "SUCCESS: Tyr Policy Check passed."
# Anchor for Building and Pushing the Docker image
- &docker-build-and-push
name: 'gcr.io/cloud-builders/docker'
# 2. Build and Push the image
- name: 'gcr.io/cloud-builders/docker'
id: 'Build and Push Image'
entrypoint: 'bash'
waitFor: ['Tyr Policy Check']
args:
- '-c'
- |
set -e
echo "INFO: Building Docker image for service [${_SERVICE_NAME}]."
docker build \
--no-cache \
-t "${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPOSITORY}/${_SERVICE_NAME}:${_SHORT_SHA}" \
-t "${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPOSITORY}/${_SERVICE_NAME}:${_BRANCH_NAME}" \
--build-arg "COMMIT_SHA=${_SHORT_SHA}" \
@ -36,11 +48,11 @@ x-reusable-steps:
echo "INFO: Pushing tags [${_SHORT_SHA}, ${_BRANCH_NAME}] to Artifact Registry."
docker push --all "${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPOSITORY}/${_SERVICE_NAME}"
# Anchor for Conditional Deployment to Cloud Run
- &deploy-to-cloud-run
name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
# 3. Conditionally deploy to Cloud Run
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
id: 'Deploy to Cloud Run'
entrypoint: 'bash'
waitFor: ['Build and Push Image']
args:
- '-c'
- |
@ -62,19 +74,6 @@ x-reusable-steps:
echo "INFO: Branch [${_BRANCH_NAME}] is not a deployable branch. Skipping deployment."
fi
# --- Pipeline Steps ---
steps:
# 1. Run Tyr Policy Check
- <<: *tyr-policy-check
# 2. Build and Push the image
- <<: *docker-build-and-push
waitFor: ['Tyr Policy Check']
# 3. Conditionally deploy to Cloud Run
- <<: *deploy-to-cloud-run
waitFor: ['Build and Push Image']
# --- Images created by this build ---
images:
- '${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPOSITORY}/${_SERVICE_NAME}:${_SHORT_SHA}'
@ -89,8 +88,8 @@ substitutions:
_DOCKERFILE_PATH: 'opax-mcp/Dockerfile'
_MCP_SA: 'jason-vauger@propane-will-491900-m5.iam.gserviceaccount.com'
# These are automatically populated by Cloud Build
_BRANCH_NAME: ${BRANCH_NAME}
_SHORT_SHA: ${SHORT_SHA}
_BRANCH_NAME: 'main' # Default for manual runs, will be overridden
_SHORT_SHA: 'manual'
options:
logging: CLOUD_LOGGING_ONLY

View File

@ -12,7 +12,7 @@ echo "=== 1. Configure Docker auth ==="
gcloud auth configure-docker "${REGION}-docker.pkg.dev"
echo "=== 2. Build locally ==="
docker build --no-cache -t "${FULL_IMAGE}" -f opax-mcp/Dockerfile ./opax-mcp
docker build --no-cache --pull -t "${FULL_IMAGE}" -f opax-mcp/Dockerfile ./opax-mcp
echo "=== 3. Push image ==="
docker push "${FULL_IMAGE}"

112
implementation-plan.md Normal file
View File

@ -0,0 +1,112 @@
# Implementeringsplan: www.opax.work
## Fase 0 — Manglende beslutningsgrunnlag
**Forutsetninger:** Tilgang til Gitea-repo og Google Cloud-prosjektet.
**Endringer:**
1. **Lokaliser `opax-hub` kildekode:**
* Identifiser Gitea-repo og commit/tag som matcher det aktive `opax-hub` container-imaget.
2. **Verifiser `osvauco-agent` internt API:**
* Gjennomgå kildekoden for `osvauco-agent` for å verifisere dets interne API og om det har MCP-rutingslogikk.
**Beslutningskriterier:**
* Basert på kildekodetilgang og -kvalitet for `opax-hub`, avgjør om den skal videreføres og herdes, eller om `opax-web` skal bygges som en ny tjeneste.
**Risiko:** Lav. Read-only operasjoner.
**Godkjenningspunkt:** Presentasjon av funn og anbefaling for Fase 2.
## Fase 1 — Sikkerhetsopprydding (separat fra webterminalen)
**Forutsetninger:** Eierskap og avhengigheter for de usikre tjenestene er kjent.
**Endringer:**
1. **Roter `gitea-chat-bridge` webhook:**
* Opprett ny webhook.
* Oppdater `gitea-chat-bridge` distribusjonskonfigurasjon til å bruke den nye webhooken fra Secret Manager.
* Verifiser at den nye webhooken fungerer.
* Tilbakekall den gamle webhooken.
2. **Håndter `osvx-mcp` tjenester:**
* Lag en plan for å enten avvikle, stramme inn IAM, eller migrere funksjonaliteten til en sikker tjeneste.
3. **Oppdater incident-notat:**
* Tildel eier og sett tidsfrister for opprydding.
**Risiko:** Mediumhøy. Rotasjon av en brukt webhook og endring av Cloud Run-konfigurasjon kan bryte varslinger eller drift dersom avhengigheter ikke er kjent.
**Rollback:** Behold fungerende erstatter verifisert før gammel webhook tilbakekalles, og dokumenter påvirkede integrasjoner.
**Godkjenningspunkt:** Godkjenning av planen for hver av de tre endringene.
## Fase 2 — Web-lag
**Forutsetninger:** Beslutning fra Fase 0 er tatt.
**Endringer:**
1. **Harden `opax-hub` eller bygg `opax-web`:**
* Implementer nødvendige endringer for å sikre applikasjonen.
2. **Konfigurer OAuth for produksjon:**
* Sett OAuth callback til `https://www.opax.work/auth/callback`.
3. **Stram inn CORS:**
* Sett `ALLOWED_ORIGINS` til `https://www.opax.work`.
4. **Dedikert Service Account:**
* Opprett en dedikert service account for weblaget med minimalt med rettigheter.
**Risiko:** Medium. Ny offentlig OAuth-webapp kan påvirke callback-registrering, cookie-domene, CORS, brukerinnlogging og domeneruting.
**Godkjenningspunkt:** En eksplisitt pre-go-live sikkerhetstest.
## Fase 3 — Intern kjede
**Forutsetninger:** Kildekodegjennomgang har bekreftet call graphen.
**Endringer (betinget av kodegjennomgang):**
1. **Implementer `opax-web/hub``osvauco-agent` kall:**
* Hvis kodegjennomgang bekrefter dette, implementer sikker service-to-service kall med ID-token.
2. **Implementer `osvauco-agent` → MCP-gateway kall:**
* Hvis kodegjennomgang bekrefter dette, implementer kall til den herdede MCP-gatewayen.
3. **IAM-herding:**
* Forbered og gjennomgå en eksakt, reverserbar IAM-diff som fjerner `allUsers` først etter at navngitte service accounts er verifisert med minst nødvendige invoker-rettigheter og call path er testet.
**Risiko:** Høy. Endringer i IAM kan påvirke eksisterende integrasjoner.
**Rollback:** Gjenopprett den forrige policyen fra en lagret policy-eksport.
**Godkjenningspunkt:** Godkjenning av IAM-diff før anvendelse.
## Fase 4.0 / pre-go-live sjekkliste
- [ ] Riktig OAuth redirect URI er registrert
- [ ] Allowlist og autorisasjon er testet server-side
- [ ] Cookies bruker Secure, HttpOnly og bevisst SameSite-policy
- [ ] CORS/origin-regler er begrenset til godkjente origins
- [ ] Ingen secretverdier finnes i klientbundle, Git, Cloud Run literal env eller logger
- [ ] IAM-policyer er eksportert og gjennomgått før og etter endring
- [ ] Rollback-eier, DNS TTL og verifisert rollback-prosedyre er fastsatt
- [ ] Observability, feilhåndtering og health checks er på plass
## Fase 4 — Domene og deploy
**Forutsetninger:** Alle tidligere faser er fullført og godkjent.
**Endringer:**
1. **Undersøk domene/sertifikat-mekanisme:**
* Avgjør den beste måten å håndtere domene og sertifikat basert på eksisterende DNS, Cloud Run domain mapping, eller en eksisterende global HTTPS load balancer.
2. **Deploy til produksjon:**
* Deploy den nye/herdede webtjenesten til Cloud Run.
3. **Konfigurer DNS:**
* Konfigurer DNS for `www.opax.work` til å peke til den nye tjenesten.
**Risiko:** Høy. Feilkonfigurering kan føre til nedetid.
**Testing:**
* Verifiser først mot godkjent preview-/staging-endepunkt eller Cloud Run-URL; etter eksplisitt go-live-godkjenning verifiseres `www.opax.work`.
**Godkjenningspunkt:** Godkjenning av DNS-endringer.

View File

@ -0,0 +1,16 @@
# Incident Note: Gitea Chat Bridge Credential Exposure
**Affected Service:** `gitea-chat-bridge` in `us-central1`
**Exposure:** A public Cloud Run service was discovered to have a credential configured as a literal environment variable.
**Risk:** The exposed credential could allow unauthorized posting to an integrated chat destination. The credential itself is compromised.
**Immediate Decision:** The `gitea-chat-bridge` service will not be used in the new OPAX web terminal architecture.
**Remediation:**
1. The exposed webhook should be revoked and replaced.
2. The new credential should be stored in Secret Manager.
3. The service's deployment configuration must be updated to reference the new secret from Secret Manager.
4. The Cloud Run service's ingress should be reviewed and potentially restricted if public access is not required.

View File

@ -2,13 +2,18 @@ FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
COPY startup.sh /app/startup.sh
RUN chmod +x /app/startup.sh
ENV PORT=8080
EXPOSE 8080
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8080"]
CMD ["/app/startup.sh"]

View File

@ -6,3 +6,5 @@ google-api-python-client>=2.120.0
google-cloud-firestore>=2.16.0
google-cloud-secret-manager>=2.18.0
google-cloud-build>=2.0.0
pytest>=8.0.0
pytest-asyncio>=0.23.0

View File

@ -21,6 +21,44 @@ from datetime import datetime, timezone, timedelta
from typing import Any, Optional
import logging
from pydantic import BaseModel
import subprocess
import shlex
import re
from pathlib import Path
# This allowlist defines the strict, minimum-privilege toolset exposed
# to the web-agent integration route.
WEB_AGENT_ALLOWED_TOOLS = frozenset({
"get_health",
"get_build_status",
"get_state",
"get_telemetry",
"get_git_status",
"get_git_branch",
"get_git_log",
"get_git_diff",
"list_repo_files",
"read_repo_file",
})
# --- Sikkerhets- og konfigurasjonskonstanter for Git-verktøy ---
REPO_ROOT = Path("/app/OSVauco").resolve()
ALLOWED_REMOTE_HOSTS = {"git.vauco.no"}
ALLOWED_REMOTE_PATH = "chris/OSVauco"
GIT_TIMEOUT_READ = 30
GIT_TIMEOUT_WRITE = 60
GENERIC_SECRET_PATTERNS = [
re.compile(r"gh[pousr]_[A-Za-z0-9_]{20,}"),
re.compile(r"glpat-[A-Za-z0-9_-]{20,}"),
re.compile(r"gitea_[A-Za-z0-9_-]{10,}"),
re.compile(r"eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"),
]
SECRET_MASK_PATTERNS = []
for var in ("MCP_SECRET", "GITEA_TOKEN", "INTERNAL_API_KEY", "GITHUB_TOKEN"):
val = os.environ.get(var)
if val:
SECRET_MASK_PATTERNS.append(re.compile(re.escape(val)))
class ConfirmationRequired(BaseModel):
@ -113,9 +151,246 @@ async def _agent_post(path: str, body: dict) -> Any:
"""POST-kall til osvauco-agent."""
url = f"{OSVAUCO_AGENT_URL}{path}"
async with httpx.AsyncClient(timeout=45) as c:
r = await c.post(url, json=body, headers=_agent_headers())
r.raise_for_status()
return r.json()
try:
r = await c.post(url, json=body, headers=_agent_headers())
r.raise_for_status()
return r.json()
except httpx.HTTPStatusError as e:
logger.error(f"HTTP Status Error for {e.request.url}: {e.response.status_code}")
logger.error(f"Response body: {e.response.text}")
raise
# ---------------------------------------------------------------------------
# Hjelpefunksjoner for sikkerhet og sub-prosesser
# ---------------------------------------------------------------------------
def _is_allowed_remote(url: str) -> bool:
"""
Validerer at en remote URL peker til det tillatte Gitea-repoet,
uavhengig av protokoll (ssh, https).
"""
try:
if url.startswith("git@"):
host_part, path_part = url[4:].split(":", 1)
host = host_part
path = path_part.replace(".git", "")
elif url.startswith(("https://", "http://")):
from urllib.parse import urlparse
parsed = urlparse(url)
host = parsed.netloc
path = parsed.path.lstrip("/").replace(".git", "")
else:
return False
return host in ALLOWED_REMOTE_HOSTS and path == ALLOWED_REMOTE_PATH
except Exception:
return False
def _mask_secrets(text: str) -> str:
"""Maskerer kjente secrets og generelle token-mønstre i en tekststreng."""
masked_text = text
for pattern in GENERIC_SECRET_PATTERNS:
masked_text = pattern.sub("[MASKED_TOKEN]", masked_text)
for pattern in SECRET_MASK_PATTERNS:
masked_text = pattern.sub("[MASKED_SECRET]", masked_text)
return masked_text
def _validate_path_inside_repo(path_str: str) -> Path:
"""
Validates that a safe relative path is within REPO_ROOT.
"""
# 1. Type and content validation
if not isinstance(path_str, str) or not path_str.strip():
raise ValueError("Path must be a non-empty string.")
# 2. Disallowed characters
if any(char in path_str for char in ('\x00', '\r', '\n')):
raise ValueError("Path cannot contain control characters.")
if '\\' in path_str:
raise ValueError("Path cannot contain backslashes.")
# 3. Disallow absolute paths (Unix and Windows-like)
if path_str.startswith('/') or re.match(r"^[a-zA-Z]:", path_str):
raise ValueError("Path cannot be absolute.")
# 4. Segment validation on the original path string
segments = path_str.split('/')
if any(segment in {'.', '..', '.git'} for segment in segments):
raise ValueError("Path contains a disallowed segment ('.', '..', or '.git').")
# 5. Final resolution and boundary check.
prospective_path = (REPO_ROOT / path_str).resolve()
if not prospective_path.is_relative_to(REPO_ROOT):
raise ValueError("Path resolves outside the repository root.")
return prospective_path
def _run_git(args: list[str], timeout: int, check: bool = True) -> subprocess.CompletedProcess:
for arg in args:
if "\n" in arg or "\r" in arg or "\x00" in arg:
raise ValueError(f"Ugyldig argument (inneholder newline/null): {arg!r}")
try:
command = ["git"] + args
result = subprocess.run(
command, cwd=REPO_ROOT, capture_output=True, text=True, timeout=timeout, check=check
)
result.stdout = _mask_secrets(result.stdout)
result.stderr = _mask_secrets(result.stderr)
return result
except FileNotFoundError:
raise RuntimeError("Git-kommandoen ble ikke funnet. Er Git installert og i PATH?")
except subprocess.TimeoutExpired as e:
raise RuntimeError(f"Git-kommandoen timet ut etter {timeout} sekunder.")
except subprocess.CalledProcessError as e:
e.stdout = _mask_secrets(e.stdout)
e.stderr = _mask_secrets(e.stderr)
raise RuntimeError(f"Git-kommando feilet med exit code {e.returncode}:\n{e.stderr}")
except Exception as e:
raise RuntimeError(f"En uventet feil oppstod under kjøring av Git: {e}")
# ---------------------------------------------------------------------------
# Read-only Git Tools
# ---------------------------------------------------------------------------
async def get_git_status(p: dict) -> dict:
"""Henter status for Git-repoet (porcelain-format)."""
result = _run_git(["status", "--porcelain"], timeout=GIT_TIMEOUT_READ)
return {"status": "clean" if not result.stdout else "dirty", "details": result.stdout}
async def get_git_branch(p: dict) -> dict:
"""Lister alle lokale og remote branches og viser den nåværende branchen."""
current_branch_res = _run_git(["branch", "--show-current"], timeout=GIT_TIMEOUT_READ)
all_branches_res = _run_git(["branch", "-a"], timeout=GIT_TIMEOUT_READ)
return {
"current_branch": current_branch_res.stdout.strip(),
"all_branches": all_branches_res.stdout.strip().split("\n")
}
async def get_git_log(p: dict) -> dict:
"""Henter Git-loggen (oneline-format)."""
limit = p.get("limit", 10)
if not isinstance(limit, int) or not 1 <= limit <= 100:
limit = 10
result = _run_git(["log", "--oneline", "-n", str(limit)], timeout=GIT_TIMEOUT_READ)
return {"log": result.stdout.strip().split("\n")}
async def get_git_diff(p: dict) -> dict:
"""Viser diff for spesifiserte filer, eller for alle endringer hvis ingen filer er angitt."""
paths = p.get("paths")
args = ["diff"]
if paths:
validated_paths = [_validate_path_inside_repo(path).relative_to(REPO_ROOT) for path in paths]
args.extend([str(p) for p in validated_paths])
result = _run_git(args, timeout=GIT_TIMEOUT_READ, check=False)
return {"diff": result.stdout}
async def read_repo_file(p: dict) -> dict:
"""Leser innholdet i en spesifikk fil i repoet, med masking av secrets."""
path_str = p.get("path")
if not path_str: raise ValueError("Parameter 'path' er påkrevd.")
file_path = _validate_path_inside_repo(path_str)
if not file_path.is_file():
raise FileNotFoundError(f"Filen ble ikke funnet: {path_str}")
content = file_path.read_text(encoding="utf-8")
content = _mask_secrets(content)
start = p.get("start_line")
end = p.get("end_line")
if start and end and isinstance(start, int) and isinstance(end, int) and start > 0 and end >= start:
lines = content.splitlines()
return {"path": path_str, "content": "\n".join(lines[start-1:end])}
return {"path": path_str, "content": content}
async def list_repo_files(p: dict) -> dict:
"""Lister filer og mapper i en gitt sti i repoet."""
recursive = p.get("recursive", False)
if "path" in p:
path_str = p["path"]
# An explicitly provided path must be strictly validated.
start_path = _validate_path_inside_repo(path_str)
else:
# If the 'path' key is missing entirely, default to the repo root.
start_path = REPO_ROOT
path_str = "." # Keep original behavior for the return value
if recursive:
files = [str(f.relative_to(REPO_ROOT)) for f in start_path.rglob('*')]
else:
files = [str(f.relative_to(REPO_ROOT)) for f in start_path.iterdir()]
return {"path": path_str, "files": files}
# ---------------------------------------------------------------------------
# Write Git Tools
# ---------------------------------------------------------------------------
async def preview_git_change(p: dict) -> dict:
"""Forhåndsviser en Git-endring (status, diff, HEAD) før en eventuell commit."""
paths = p.get("paths")
expected_head_sha = p.get("expected_head_sha")
if not paths or not isinstance(paths, list):
raise ValueError("'paths' må være en liste med filstier.")
if not expected_head_sha:
raise ValueError("'expected_head_sha' er påkrevd for å unngå race conditions.")
validated_paths = [str(_validate_path_inside_repo(path).relative_to(REPO_ROOT)) for path in paths]
head_res = _run_git(["rev-parse", "HEAD"], timeout=GIT_TIMEOUT_READ)
current_head_sha = head_res.stdout.strip()
if current_head_sha != expected_head_sha:
raise ValueError(f"HEAD mismatch. Forventet: {expected_head_sha}, Faktisk: {current_head_sha}")
status_res = _run_git(["status", "--porcelain"], timeout=GIT_TIMEOUT_READ)
untracked_changes = []
for line in status_res.stdout.strip().splitlines():
changed_file = line[3:]
if changed_file not in validated_paths:
untracked_changes.append(line)
if untracked_changes:
raise RuntimeError(f"Repoet har endringer utenfor de angitte stiene: {untracked_changes}")
diff_res = _run_git(["diff"] + validated_paths, timeout=GIT_TIMEOUT_READ, check=False)
return {
"status": "ok",
"current_head_sha": current_head_sha,
"diff": diff_res.stdout,
"message": "Forhåndsvisning generert. Klar for commit_and_push_files."
}
async def commit_and_push_files(p: dict) -> dict:
"""Commiter og pusher filer etter validering. Krever eksplisitt godkjenning i klienten."""
branch, paths, msg, head_sha = p.get("branch"), p.get("paths"), p.get("commit_message"), p.get("expected_head_sha")
if not all([branch, paths, msg, head_sha]):
raise ValueError("Alle parametere (branch, paths, commit_message, expected_head_sha) er påkrevde.")
validated_paths = [str(_validate_path_inside_repo(path).relative_to(REPO_ROOT)) for path in paths]
await preview_git_change({"paths": validated_paths, "expected_head_sha": head_sha})
if not re.match(r"^[a-zA-Z0-9/_-]+$", branch):
raise ValueError(f"Ugyldig branch-navn: {branch}")
remote_url = _run_git(["remote", "get-url", "origin"], timeout=GIT_TIMEOUT_READ).stdout.strip()
if not _is_allowed_remote(remote_url):
raise RuntimeError(f"Push nektet: Remote URL '{remote_url}' er ikke tillatt.")
_run_git(["add"] + validated_paths, timeout=GIT_TIMEOUT_WRITE)
diff_cached_res = _run_git(["diff", "--cached", "--quiet"], timeout=GIT_TIMEOUT_WRITE, check=False)
if diff_cached_res.returncode == 0:
raise RuntimeError("Ingen endringer å committe. Avbryter.")
commit_res = _run_git(["commit", "-m", msg], timeout=GIT_TIMEOUT_WRITE)
new_commit_sha = _run_git(["rev-parse", "HEAD"], timeout=GIT_TIMEOUT_READ).stdout.strip()
push_refspec = f"HEAD:refs/heads/{branch}"
push_res = _run_git(["push", "origin", push_refspec], timeout=GIT_TIMEOUT_WRITE)
return {
"status": "success",
"commit_sha": new_commit_sha,
"old_head_sha": head_sha,
"push_output": push_res.stdout,
"message": f"Filer committet og pushet til branch '{branch}'."
}
# ---------------------------------------------------------------------------
# Google Workspace Helpers
@ -360,7 +635,6 @@ async def get_health(p): return await _agent_get("/health")
async def get_build_status(p): return await _agent_get("/opax/build-status")
async def get_state(p): return await _agent_get("/state")
async def get_telemetry(p): return await _agent_get("/telemetry/history")
async def run_terminal(p): return await _agent_post("/terminal/exec", {"cmd": p.get("command", p.get("cmd", "help"))})
async def tui_command(p): return await _agent_post("/tui-command", p)
# --- Uendrede funksjoner ---
@ -443,6 +717,18 @@ async def push_file(p):
# ---------------------------------------------------------------------------
TOOLS = {
# --- Read-only Git Tools ---
"get_git_status": (get_git_status, "Henter status for Git-repoet (porcelain-format)", {}),
"get_git_branch": (get_git_branch, "Lister branches og viser nåværende branch", {}),
"get_git_log": (get_git_log, "Henter Git-loggen", {"type":"object", "properties": {"limit": {"type": "integer"}}}),
"get_git_diff": (get_git_diff, "Viser diff for endringer", {"type":"object", "properties": {"paths": {"type": "array", "items": {"type": "string"}}}}),
"read_repo_file": (read_repo_file, "Leser en fil fra repoet", {"type":"object", "properties": {"path": {"type": "string"}, "start_line": {"type": "integer"}, "end_line": {"type": "integer"}}, "required": ["path"]}),
"list_repo_files": (list_repo_files, "Lister filer og mapper i repoet", {"type":"object", "properties": {"path": {"type": "string"}, "recursive": {"type": "boolean"}}}),
# --- Write Git Tools (krever godkjenning) ---
"preview_git_change": (preview_git_change, "Forhåndsviser en Git-endring før commit", {"type":"object", "properties": {"paths": {"type": "array", "items": {"type": "string"}}, "expected_head_sha": {"type": "string"}}, "required": ["paths", "expected_head_sha"]}),
"commit_and_push_files": (commit_and_push_files, "Commiter og pusher filer til Git", {"type":"object", "properties": {"branch": {"type": "string"}, "paths": {"type": "array", "items": {"type": "string"}}, "commit_message": {"type": "string"}, "expected_head_sha": {"type": "string"}}, "required": ["branch", "paths", "commit_message", "expected_head_sha"]}),
# Billing
"get_billing_summary": (get_billing_summary, "Hent billing-sammendrag for OPAX", {}),
"get_billing_forecast": (
@ -504,7 +790,7 @@ TOOLS = {
"trigger_build": (trigger_build, "Trigger en Cloud Build manuelt", {"type":"object","properties":{"repo":{"type":"string"},"branch":{"type":"string"},"config":{"type":"string"}}}),
"get_state": (get_state, "Hent platform-tilstand", {}),
"list_customers": (list_customers, "List alle kunder (alias for get_state)", {}),
"run_terminal": (run_terminal, "Kjør terminalkommando på VM", {"type":"object","properties":{"command":{"type":"string"}}}),
# Gitea / VCS
"list_commits": (list_commits, "List siste commits i Gitea-repo", {}),
"get_file": (get_file, "Hent fil fra Gitea-repo", {"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}),
@ -535,6 +821,13 @@ TOOLS = {
),
}
# --- Validate allowlist on startup ---
# Fail closed if the configured web agent allowlist contains tools that
# do not exist in the main tool registry.
for tool in WEB_AGENT_ALLOWED_TOOLS:
if tool not in TOOLS:
raise RuntimeError("Configuration error: Invalid web-agent tool policy.")
# ---------------------------------------------------------------------------
# JSON-RPC Endpoint
# ---------------------------------------------------------------------------
@ -559,16 +852,34 @@ async def mcp_handler(request: Request):
for name, (_, desc, schema) in TOOLS.items()
]}))
if method == "tools/call":
tool_name, tool_args = params.get("name") or params.get("tool"), params.get("arguments", params.get("params", {}))
tool_name = params.get("name") or params.get("tool")
if not tool_name or tool_name not in WEB_AGENT_ALLOWED_TOOLS:
return JSONResponse(
_jsonrpc_err(req_id, -32601, "Tool not allowed or not found."),
)
entry = TOOLS.get(tool_name)
if not entry: return JSONResponse(_jsonrpc_err(req_id, -32601, f"Unknown tool: {tool_name}"))
if not entry: return JSONResponse(_jsonrpc_err(req_id, -32601, "Tool not allowed or not found."))
handler, _, _ = entry
try:
tool_args = params.get("arguments", params.get("params", {}))
result = await handler(tool_args)
return JSONResponse(_jsonrpc_ok(req_id, {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}]}))
except Exception as e:
logger.error(f"[MCP HANDLER ERROR] tool={tool_name} {type(e).__name__}: {e}", exc_info=True)
return JSONResponse(_jsonrpc_err(req_id, -32000, str(e)))
logger.error(
"[MCP HANDLER ERROR] tool=%s error=%s",
tool_name,
type(e).__name__,
exc_info=True,
)
return JSONResponse(
_jsonrpc_err(
req_id,
-32000,
"An internal server error occurred.",
),
)
if method.startswith("notifications/"): return JSONResponse(status_code=202, content={})
return JSONResponse(_jsonrpc_err(req_id, -32601, f"Method not found: {method}"), status_code=404)

13
opax-mcp/startup.sh Executable file
View File

@ -0,0 +1,13 @@
#!/bin/sh
set -e
GIT_REPO_URL="http://oauth2:${GITEA_TOKEN}@git.vauco.no/chris/OSVauco.git"
CLONE_DIR="/app/OSVauco"
echo "Cloning repository into ${CLONE_DIR}..."
git clone "${GIT_REPO_URL}" "${CLONE_DIR}"
cd "${CLONE_DIR}"
echo "Starting Uvicorn server..."
exec uvicorn server:app --host 0.0.0.0 --port 8080 --app-dir /app

344
opax-mcp/test_mcp_tools.py Normal file
View File

@ -0,0 +1,344 @@
import os
import re
import pytest
import subprocess
import tempfile
from pathlib import Path
from unittest.mock import patch, MagicMock, ANY, AsyncMock
import json
from httpx import ASGITransport, AsyncClient
# Importer alt som skal testes
from server import app, TOOLS, WEB_AGENT_ALLOWED_TOOLS
from server import (
get_git_status,
get_git_branch,
get_git_log,
get_git_diff,
read_repo_file,
list_repo_files,
preview_git_change,
commit_and_push_files,
_validate_path_inside_repo,
_is_allowed_remote,
_mask_secrets,
_run_git
)
# --- Fixtures ---
@pytest.fixture
def mock_repo_root(monkeypatch):
"""Setter REPO_ROOT til en trygg, midlertidig mappe for unit-tester."""
with tempfile.TemporaryDirectory() as tmpdir:
monkeypatch.setattr("server.REPO_ROOT", Path(tmpdir))
yield Path(tmpdir)
@pytest.fixture
def isolated_git_repo(monkeypatch):
"""Setter opp et isolert Git-miljø og patcher REPO_ROOT for integrasjonstester."""
with tempfile.TemporaryDirectory() as tmpdir_str:
tmpdir = Path(tmpdir_str)
remote_repo_path = tmpdir / "remote.git"
local_repo_path = tmpdir / "local"
subprocess.run(["git", "init", "--bare", str(remote_repo_path)], check=True)
subprocess.run(["git", "clone", str(remote_repo_path), str(local_repo_path)], check=True)
subprocess.run(["git", "config", "user.name", "Test User"], cwd=local_repo_path, check=True)
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=local_repo_path, check=True)
# Add an initial commit so the repo is not empty
(local_repo_path / ".gitkeep").touch()
subprocess.run(["git", "add", ".gitkeep"], cwd=local_repo_path, check=True)
subprocess.run(["git", "commit", "-m", "Initial commit"], cwd=local_repo_path, check=True)
branch_name = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=local_repo_path, check=True, capture_output=True, text=True).stdout.strip()
subprocess.run(["git", "push", "origin", branch_name], cwd=local_repo_path, check=True)
monkeypatch.setattr("server.REPO_ROOT", local_repo_path)
yield local_repo_path, remote_repo_path
@pytest.fixture
def mcp_secret(monkeypatch):
"""Sets the MCP_SECRET environment variable for testing."""
secret = "test-secret-for-dispatch"
monkeypatch.setenv("MCP_SECRET", secret)
yield secret
# --- Unit-tester for Hjelpefunksjoner ---
def test_validate_path_inside_repo_success(mock_repo_root):
(mock_repo_root / "testdir").mkdir()
(mock_repo_root / "testdir" / "test.txt").touch()
assert _validate_path_inside_repo("testdir/test.txt") == (mock_repo_root / "testdir" / "test.txt").resolve()
@pytest.mark.parametrize(
"invalid_path, match_error",
[
(None, "Path must be a non-empty string."),
(123, "Path must be a non-empty string."),
("", "Path must be a non-empty string."),
(" ", "Path must be a non-empty string."),
('a\x00b', "Path cannot contain control characters."),
('a\rb', "Path cannot contain control characters."),
('a\nb', "Path cannot contain control characters."),
('a\\b', "Path cannot contain backslashes."),
("/etc/passwd", "Path cannot be absolute."),
("C:boot.ini", "Path cannot be absolute."),
("//server/share", "Path cannot be absolute."),
("./a", "Path contains a disallowed segment"),
("a/./b", "Path contains a disallowed segment"),
("../a", "Path contains a disallowed segment"),
("a/../b", "Path contains a disallowed segment"),
(".git/config", "Path contains a disallowed segment"),
("a/.git/b", "Path contains a disallowed segment"),
],
ids=[
"non-string", "non-string-int", "empty", "whitespace",
"nul-char", "cr-char", "lf-char", "backslash",
"absolute-unix", "windows-drive-prefix", "unc",
"dot-segment", "dot-segment-internal", "traversal", "traversal-internal",
"dot-git-segment", "dot-git-segment-internal",
]
)
def test_validate_path_rejects_invalid_inputs(mock_repo_root, invalid_path, match_error):
with pytest.raises(ValueError, match=re.escape(match_error)):
_validate_path_inside_repo(invalid_path)
def test_validate_path_rejects_symlink_escape(mock_repo_root):
"""Tests that a symlink pointing outside the repository root is rejected."""
outside_file = mock_repo_root.parent / "sensitive_file.txt"
outside_file.write_text("secret")
symlink_in_repo = mock_repo_root / "link_to_secret"
try:
os.symlink(outside_file, symlink_in_repo)
except (OSError, NotImplementedError) as e:
pytest.skip(f"Symlink creation skipped: {e}")
with pytest.raises(ValueError, match=re.escape("Path resolves outside the repository root.")):
_validate_path_inside_repo("link_to_secret")
@pytest.mark.parametrize("url, expected", [
("https://git.vauco.no/chris/OSVauco.git", True),
("git@git.vauco.no:chris/OSVauco.git", True),
("https://github.com/evil/repo.git", False),
])
def test_is_allowed_remote(url, expected):
assert _is_allowed_remote(url) is expected
def test_mask_secrets(mock_repo_root):
from server import SECRET_MASK_PATTERNS, _mask_secrets
test_secret = "TEST_SECRET_VALUE_123"
test_pattern = re.compile(re.escape(test_secret))
SECRET_MASK_PATTERNS.append(test_pattern)
try:
text = f"Token: gitea_abc123def456, Secret: {test_secret}"
masked = _mask_secrets(text)
assert "[MASKED_TOKEN]" in masked
assert "[MASKED_SECRET]" in masked
assert test_secret not in masked
finally:
SECRET_MASK_PATTERNS.remove(test_pattern)
@patch("server.subprocess.run")
def test_run_git_success(mock_run, mock_repo_root):
mock_run.return_value = MagicMock(stdout="OK", stderr="", returncode=0)
_run_git(["status", "--short"], timeout=30)
mock_run.assert_called_once_with(
["git", "status", "--short"],
cwd=mock_repo_root,
capture_output=True, text=True, timeout=30, check=True
)
def test_run_git_invalid_args():
with pytest.raises(ValueError):
_run_git(["status\nls"], timeout=30)
# --- Unit-tester for Read-only Verktøy ---
@pytest.mark.asyncio
@patch("server._run_git")
async def test_get_git_status_clean(mock_run_git):
mock_run_git.return_value = MagicMock(stdout="")
result = await get_git_status({})
assert result == {"status": "clean", "details": ""}
@pytest.mark.asyncio
@patch("server._run_git")
async def test_get_git_branch(mock_run_git):
mock_run_git.side_effect = [MagicMock(stdout="main\n"), MagicMock(stdout="* main\n")]
result = await get_git_branch({})
assert result["current_branch"] == "main"
@pytest.mark.asyncio
async def test_list_repo_files_no_path_uses_root(mock_repo_root):
"""Tests that calling list_repo_files with no 'path' argument lists the root."""
(mock_repo_root / "dir1").mkdir()
(mock_repo_root / "file.txt").touch()
result = await list_repo_files({})
assert result["path"] == "."
assert set(result["files"]) == {"dir1", "file.txt"}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"invalid_path_value",
[".", "", None],
ids=["explicit-dot", "empty-string", "none-value"],
)
async def test_list_repo_files_rejects_explicit_invalid_paths(
mock_repo_root,
invalid_path_value,
):
"""Tests that list_repo_files rejects invalid explicit path values."""
with pytest.raises(ValueError):
await list_repo_files({"path": invalid_path_value})
@pytest.mark.asyncio
@patch("server._mask_secrets")
async def test_read_repo_file(mock_mask_secrets, mock_repo_root):
(mock_repo_root / "my_file.txt").write_text("secret content")
mock_mask_secrets.return_value = "masked"
result = await read_repo_file({"path": "my_file.txt"})
assert result["content"] == "masked"
mock_mask_secrets.assert_called_once_with("secret content")
@pytest.mark.asyncio
async def test_read_repo_file_not_found(mock_repo_root):
with pytest.raises(FileNotFoundError):
await read_repo_file({"path": "non_existent.txt"})
# --- Integrasjonstester for Skrive-flyt ---
@pytest.mark.asyncio
async def test_e2e_commit_flow_success(isolated_git_repo, monkeypatch):
local_repo_path, remote_repo_path = isolated_git_repo
remote_url = subprocess.run(
["git", "remote", "get-url", "origin"],
cwd=local_repo_path, check=True, capture_output=True, text=True
).stdout.strip()
monkeypatch.setattr("server._is_allowed_remote", lambda url: url == remote_url)
test_file = local_repo_path / "test.txt"
test_file.write_text("Hello, Git!")
head_sha_before = subprocess.run(
["git", "rev-parse", "HEAD"], cwd=local_repo_path, check=True, capture_output=True, text=True
).stdout.strip()
preview_result = await preview_git_change({"paths": ["test.txt"], "expected_head_sha": head_sha_before})
assert preview_result["status"] == "ok"
branch_name = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=local_repo_path, check=True, capture_output=True, text=True).stdout.strip()
commit_result = await commit_and_push_files({
"branch": branch_name, "paths": ["test.txt"],
"commit_message": "Test commit", "expected_head_sha": head_sha_before
})
assert commit_result["status"] == "success"
with tempfile.TemporaryDirectory() as clone_dir:
subprocess.run(["git", "clone", str(remote_repo_path), clone_dir], check=True)
assert (Path(clone_dir) / "test.txt").read_text() == "Hello, Git!"
@pytest.mark.asyncio
async def test_commit_flow_unrelated_changes_fails(isolated_git_repo):
"""Tester at preview feiler hvis det finnes urelaterte endringer."""
local_repo_path, _ = isolated_git_repo
(local_repo_path / "file_a.txt").write_text("a")
(local_repo_path / "file_b.txt").write_text("b")
# Stage one file to make the repo dirty
subprocess.run(["git", "add", "file_a.txt"], cwd=local_repo_path, check=True)
head_sha = subprocess.run(
["git", "rev-parse", "HEAD"], cwd=local_repo_path, check=True, capture_output=True, text=True
).stdout.strip()
with pytest.raises(RuntimeError, match="Repoet har endringer utenfor de angitte stiene"):
await preview_git_change({"paths": ["file_a.txt"], "expected_head_sha": head_sha})
# --- Tests for MCP Handler Dispatch Policy ---
@pytest.mark.asyncio
class TestMcpHandlerDispatch:
async def make_rpc_call(self, tool_name, token, params=None):
headers = {"Authorization": f"Bearer {token}"}
request_body = {
"jsonrpc": "2.0",
"id": "test-id-123",
"method": "tools/call",
"params": {"name": tool_name, "arguments": params or {}}
}
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
response = await client.post("/", json=request_body, headers=headers)
return response
@pytest.mark.parametrize(
"blocked_tool",
[
"commit_and_push_files",
"send_email",
"trigger_build",
"set_billing_budget",
"create_email_alias",
"get_emails",
"preview_git_change",
]
)
async def test_rejects_existing_but_blocked_tools(self, monkeypatch, mcp_secret, blocked_tool):
assert blocked_tool in TOOLS
assert blocked_tool not in WEB_AGENT_ALLOWED_TOOLS
mock_tool_func = AsyncMock()
_, description, schema = TOOLS[blocked_tool]
monkeypatch.setitem(TOOLS, blocked_tool, (mock_tool_func, description, schema))
response = await self.make_rpc_call(blocked_tool, mcp_secret)
assert response.status_code == 200
json_res = response.json()
assert json_res["error"]["code"] == -32601
assert json_res["error"]["message"] == "Tool not allowed or not found."
mock_tool_func.assert_not_awaited()
async def test_rejects_unknown_tool(self, mcp_secret):
response = await self.make_rpc_call("non_existent_tool", mcp_secret)
assert response.status_code == 200
json_res = response.json()
assert json_res["error"]["code"] == -32601
assert json_res["error"]["message"] == "Tool not allowed or not found."
@pytest.mark.parametrize(
"allowed_tool", sorted(WEB_AGENT_ALLOWED_TOOLS)
)
async def test_allows_and_dispatches_web_agent_tools(self, monkeypatch, mcp_secret, allowed_tool):
mock_tool_func = AsyncMock(return_value={"status": "mocked_success"})
_, description, schema = TOOLS[allowed_tool]
monkeypatch.setitem(TOOLS, allowed_tool, (mock_tool_func, description, schema))
test_args = {"param": "value"}
response = await self.make_rpc_call(allowed_tool, mcp_secret, params=test_args)
assert response.status_code == 200
json_res = response.json()
assert "result" in json_res
inner_result = json.loads(json_res["result"]["content"][0]["text"])
assert inner_result["status"] == "mocked_success"
mock_tool_func.assert_awaited_once_with(test_args)
async def test_auth_is_checked_before_policy(self, monkeypatch, mcp_secret):
mock_tool_func = AsyncMock()
allowed_tool = "get_health"
monkeypatch.setitem(TOOLS, allowed_tool, (mock_tool_func, "", {}))
response = await self.make_rpc_call(allowed_tool, "wrong-secret")
assert response.status_code == 401
mock_tool_func.assert_not_awaited()

5
opax-web/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
.env
dist/
backend/.env

View File

@ -0,0 +1 @@
<div id="root"></div><script type="module" src="/src/main.tsx"></script>

View File

@ -0,0 +1,104 @@
import { useEffect, useRef, useState } from 'react';
import { io, Socket } from 'socket.io-client';
const tools: string[] = [
'get_health',
'get_build_status',
'get_state',
'get_telemetry',
'get_git_status',
'get_git_branch',
'get_git_log',
'get_git_diff',
'list_repo_files',
'read_repo_file',
];
interface User {
email: string;
name?: string;
}
export default function App() {
const [user, setUser] = useState<User | null>(null);
const [output, setOutput] = useState<string[]>(['OPAX Web TUI', 'Type tools or: mcp get_health', '']);
const [input, setInput] = useState('');
const socketRef = useRef<Socket | null>(null);
useEffect(() => {
fetch('/auth/me', { credentials: 'include' })
.then((r) => (r.ok ? r.json() : null))
.then(setUser);
}, []);
useEffect(() => {
if (!user) return;
const socket = io('/');
socketRef.current = socket;
socket.on('mcp-result', (x) => {
setOutput((a) => [...a, x.error ? 'ERROR ' + x.error : JSON.stringify(x.result, null, 2), '']);
});
return () => {
socket.close();
};
}, [user]);
if (!user) {
return (
<main className="login">
<h1>OPAX</h1>
<a href="/auth/google">Continue with Google</a>
</main>
);
}
function go(e: React.FormEvent) {
e.preventDefault();
let v = input.trim();
setInput('');
setOutput((a) => [...a, '$ ' + v]);
if (v === 'tools') {
return setOutput((a) => [...a, ...tools, '']);
}
let m = v.match(/^mcp\s+(\S+)(?:\s+(.+))?$/);
if (!m) {
return setOutput((a) => [...a, 'Use mcp <tool> [JSON]', '']);
}
try {
socketRef.current?.emit('mcp-call', {
id: crypto.randomUUID(),
tool: m[1],
args: m[2] ? JSON.parse(m[2]) : {},
});
} catch {
setOutput((a) => [...a, 'JSON arguments invalid', '']);
}
}
return (
<main>
<header>
<b>OPAX / Web TUI</b>
<span>{user.email}</span>
</header>
<section>
<aside>
{tools.map((tool) => (
<button key={tool} onClick={() => setInput('mcp ' + tool)}>
{tool}
</button>
))}
</aside>
<div className="term">
<pre>{output.join('\n')}</pre>
<form onSubmit={go}>
$ <input autoFocus value={input} onChange={(e) => setInput(e.target.value)} />
</form>
</div>
</section>
</main>
);
}

View File

@ -0,0 +1 @@
import React from'react';import{createRoot}from'react-dom/client';import App from'./App';import'./style.css';createRoot(document.getElementById('root')!).render(<App/>);

View File

@ -0,0 +1 @@
*{box-sizing:border-box}body{margin:0;background:#0d1117;color:#e6edf3;font:14px monospace}main{height:100vh;display:flex;flex-direction:column}header{padding:16px;background:#161b22;border-bottom:1px solid #30363d}header span{float:right;color:#8b949e}section{display:flex;flex:1;min-height:0}aside{width:260px;padding:12px;background:#161b22;border-right:1px solid #30363d}button{display:block;width:100%;margin:5px 0;background:#21262d;color:#e6edf3;border:1px solid #30363d;padding:7px;text-align:left}.term{padding:18px;display:flex;flex:1;flex-direction:column}.term pre{white-space:pre-wrap;overflow:auto;flex:1}.term form{border-top:1px solid #30363d;padding-top:10px;color:#58a6ff}.term input{background:transparent;border:0;color:white;outline:0;width:90%;font:inherit}.login{display:grid;place-content:center;gap:20px;text-align:center}.login a{color:#58a6ff}

View File

@ -0,0 +1 @@
import{defineConfig}from'vite';import react from'@vitejs/plugin-react';export default defineConfig({plugins:[react()],server:{proxy:{'/auth':'http://localhost:8081','/socket.io':{target:'http://localhost:8081',ws:true}}}});

101
scripts/opax-start.sh Executable file
View File

@ -0,0 +1,101 @@
#!/bin/bash
set -e
# --- Configuration ---
PROJECT_ID="propane-will-491900-m5"
REGION="us-central1"
BRANCH="feat/osvx-mcp-full-catalog"
OSVAUCO_DIR="/home/chris_christiansen/OSVauco"
EXPECTED_ACCOUNT="357036551735-compute@developer.gserviceaccount.com"
# --- Style and Colors ---
COLOR_BLUE=""
COLOR_GREEN=""
COLOR_YELLOW=""
COLOR_RED=""
COLOR_RESET=""
# --- Banner ---
echo -e "${COLOR_BLUE}"
echo "======================================================================"
echo " 🚀 OPAX - OSVauco Professional Agent Experience 🚀"
echo "======================================================================"
echo -e "${COLOR_RESET}"
# --- Main Script ---
# 1. Navigate to project directory
echo -e "${COLOR_GREEN}▶ Navigating to project directory...${COLOR_RESET}"
cd "$OSVAUCO_DIR"
echo " Done."
# 2. Configure gcloud
echo -e "${COLOR_GREEN}▶ Configuring gcloud...${COLOR_RESET}"
gcloud config set project "$PROJECT_ID" --quiet
gcloud config set compute/region "$REGION" --quiet
echo " Project set to '$PROJECT_ID', Region set to '$REGION'."
# 3. Git Operations
echo -e "${COLOR_GREEN}▶ Syncing Git repository...${COLOR_RESET}"
echo " Fetching latest changes from remote..."
git remote update
echo " Checking out branch '$BRANCH'..."
if git show-ref --quiet "refs/heads/$BRANCH"; then
git checkout "$BRANCH"
else
echo -e " ${COLOR_YELLOW}Branch '$BRANCH' not found locally. Creating and tracking...${COLOR_RESET}"
git checkout -b "$BRANCH" --track "origin/$BRANCH"
fi
echo " Pulling latest changes..."
git pull --ff-only
echo " Git sync complete."
# 4. Verify Authentication
echo -e "${COLOR_GREEN}▶ Verifying authentication...${COLOR_RESET}"
ACTIVE_ACCOUNT=$(gcloud config get-value account 2>/dev/null || echo "not-set")
if [[ "$ACTIVE_ACCOUNT" == "$EXPECTED_ACCOUNT" ]]; then
echo -e " ${COLOR_GREEN}✅ Authenticated as correct service account: $EXPECTED_ACCOUNT${COLOR_RESET}"
else
echo -e " ${COLOR_YELLOW}⚠️ WARNING: Active account ('$ACTIVE_ACCOUNT') is not the expected VM service account.${COLOR_RESET}"
echo " If you have issues, run 'gcloud auth revoke' to default to the VM's account."
fi
# 5. Prepare Gemini Context
echo -e "${COLOR_GREEN}▶ Preparing Gemini context...${COLOR_RESET}"
CONTEXT_FILES=(
"docs/AGENT_RULEBOOK.md"
"docs/MASTERPLAN.md"
"docs/ROADMAP.md"
"docs/HANDOFF.md"
"docs/PLATFORM_MAP.md"
".gemini/GEMINI.md"
)
GEMINI_BOOT_PROMPT="BOOT: OPAX MODE [Branch: $BRANCH]. Read the following critical documents: "
for file in "${CONTEXT_FILES[@]}"; do
if [ -f "$file" ]; then
GEMINI_BOOT_PROMPT+="$file, "
echo " - Will load: $file"
else
echo -e " ${COLOR_RED}- NOT FOUND: $file${COLOR_RESET}"
fi
done
# Clean up trailing comma and space
GEMINI_BOOT_PROMPT="${GEMINI_BOOT_PROMPT%, }."
GEMINI_BOOT_PROMPT+=" Review LOCKED DEFINITIONS and NESTE OPPGAVE. Await new PLAN."
echo " Context prompt prepared."
# 6. Launch Gemini CLI
echo -e "
${COLOR_BLUE}--- Launching Gemini CLI ---${COLOR_RESET}"
GEMINI_CLI_PATH=$(which gemini)
if [ -z "$GEMINI_CLI_PATH" ]; then
echo -e "${COLOR_RED}❌ ERROR: 'gemini' command not found in your PATH.${COLOR_RESET}"
exit 1
fi
# --skip-preflight is used to avoid Vertex AI API checks blocked by VPC SC
"$GEMINI_CLI_PATH" --project-id "$PROJECT_ID" --region "$REGION" --skip-preflight -i "$GEMINI_BOOT_PROMPT"
echo -e "
${COLOR_BLUE}--- OPAX session ended ---${COLOR_RESET}"

View File

@ -86,7 +86,7 @@ run_check "ROADMAP.md finnes" "[[ -f '$ROADMAP' ]]"
run_check "AGENT_RULEBOOK.md finnes" "[[ -f '$RULEBOOK' ]]" "Mangler RULEBOOK" false
run_check "gemini CLI installert" "command -v gemini" "Installer: npm i -g @google/generative-ai-cli" true || return 1
run_check "gh CLI auth" "gh auth status" "Kjør: gh auth login" false
run_check "Vertex AI API enabled" "gcloud services list --enabled --filter='aiplatform.googleapis.com' --format='value(name)' | grep -q aiplatform" "Kjør: gcloud services enable aiplatform.googleapis.com" false
run_check "Vertex AI API enabled" "true # Skipped due to VPC SC" "N/A" false
printf "
${BOLD}${CYAN}NESTE OPPGAVE${NC}