diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index eb486ad381f1..bebdae1ba2f6 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -52,6 +52,18 @@ {"id":"opencode-10.3.4","title":"Expose attach port 4096 on router Service and pass env vars in homelab/src/index.ts","status":"closed","priority":2,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-05-12T21:33:10.562572+02:00","created_by":"Oliver Jägle","updated_at":"2026-05-12T21:37:25.337583+02:00","closed_at":"2026-05-12T21:37:25.337583+02:00","close_reason":"Closed","dependencies":[{"issue_id":"opencode-10.3.4","depends_on_id":"opencode-10.3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]} {"id":"opencode-10.3.5","title":"Update operator tests for attach route provisioning","status":"closed","priority":2,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-05-12T21:33:10.811076+02:00","created_by":"Oliver Jägle","updated_at":"2026-05-12T21:37:25.495618+02:00","closed_at":"2026-05-12T21:37:25.495618+02:00","close_reason":"Closed","dependencies":[{"issue_id":"opencode-10.3.5","depends_on_id":"opencode-10.3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]} {"id":"opencode-10.4","title":"Commit","description":"Ensure code quality and documentation accuracy through systematic cleanup and review. **STEP 1: Code Cleanup** Systematically clean up development artifacts: 1. **Remove Debug Output**: Search for and remove all temporary debug output statements used during development. Look for language-specific debug output methods (console logging, print statements, debug output functions). Remove any debugging statements that were added for development purposes. 2. **Review TODO/FIXME Comments**: - Address each TODO/FIXME comment by either implementing the solution or documenting why it's deferred - Remove completed TODOs - Convert remaining TODOs to proper issue tracking if needed 3. **Remove Debugging Code Blocks**: - Remove temporary debugging code, test code blocks, and commented-out code - Clean up any experimental code that's no longer needed - Ensure proper error handling replaces temporary debug logging **STEP 2: Documentation Review** Review and update documentation to reflect final implementation: 1. **Update Long-Term Memory Documents**: Based on what was actually implemented: - If exists: Update it if requirements changed during development - If exists: Update it if architectural impacts were identified - If exists: Update it if design details were refined or changed - Otherwise: Document any changes in the plan file 2. **Compare Against Implementation**: Review documentation against actual implemented functionality 3. **Update Changed Sections**: Only modify documentation sections that have functional changes 4. **Remove Development Progress**: Remove references to development iterations, progress notes, and temporary decisions 5. **Focus on Final State**: Ensure documentation describes the final implemented state, not the development process 6. **Ask User to Review Document Updates** **STEP 3: Final Validation** - Run existing tests to ensure cleanup didn't break functionality - Verify documentation accuracy with a final review - Ensure code is ready for production/delivery Update task progress and mark completed work as you finalize the feature.","status":"open","priority":3,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-05-12T21:04:59.040599+02:00","created_by":"Oliver Jägle","updated_at":"2026-05-12T21:04:59.040599+02:00","dependencies":[{"issue_id":"opencode-10.4","depends_on_id":"opencode-10","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"opencode-10.4","depends_on_id":"opencode-10.3","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]} +{"id":"opencode-11","title":"opencode: bugfix (development-plan-fix-stable-hash-session-without-repo.md)","description":"Responsible vibe engineering session using bugfix workflow for opencode","status":"open","priority":2,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-05-13T10:11:44.590065+02:00","created_by":"Oliver Jägle","updated_at":"2026-05-13T10:11:44.590065+02:00"} +{"id":"opencode-11.1","title":"Reproduce","description":"Gather specific information to reliably reproduce the reported bug: - What are the exact OS, browser/runtime versions, and hardware specs? - What is the precise sequence of actions that trigger the bug? - What error messages, logs, or stack traces are available? - Does this happen every time or intermittently? - How many users are affected and what is the business impact? Create test cases that demonstrate the problem. Document your findings and create tasks as needed.","status":"open","priority":3,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-05-13T10:11:44.837357+02:00","created_by":"Oliver Jägle","updated_at":"2026-05-13T10:11:44.837357+02:00","dependencies":[{"issue_id":"opencode-11.1","depends_on_id":"opencode-11","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]} +{"id":"opencode-11.1.1","title":"Confirm: getSessionHash called 3x independently in no-repo path → 3 different hashes","status":"closed","priority":2,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-05-13T10:17:39.464197+02:00","created_by":"Oliver Jägle","updated_at":"2026-05-13T10:17:42.532162+02:00","closed_at":"2026-05-13T10:17:42.532162+02:00","close_reason":"Closed","dependencies":[{"issue_id":"opencode-11.1.1","depends_on_id":"opencode-11.1","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]} +{"id":"opencode-11.2","title":"Analyze","description":"Examine the code paths involved in the bug, identify the root cause, and understand why the issue occurs. Use debugging tools, add logging, and trace through the problematic code. Document your analysis and create tasks as needed.","status":"open","priority":3,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-05-13T10:11:45.084848+02:00","created_by":"Oliver Jägle","updated_at":"2026-05-13T10:11:45.084848+02:00","dependencies":[{"issue_id":"opencode-11.2","depends_on_id":"opencode-11","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"opencode-11.2","depends_on_id":"opencode-11.1","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]} +{"id":"opencode-11.3","title":"Fix","description":"Implement the solution based on your analysis: - If exists: Follow the design from it - Otherwise: Elaborate design options and present them to the user Before implementing, assess the approach: - How critical is this system? What is the blast radius if the fix causes issues? - Should this be a minimal fix or a more comprehensive solution? Make targeted changes that address the root cause without introducing new issues. Be careful to maintain existing functionality while fixing the bug.","status":"open","priority":3,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-05-13T10:11:45.285088+02:00","created_by":"Oliver Jägle","updated_at":"2026-05-13T10:11:45.285088+02:00","dependencies":[{"issue_id":"opencode-11.3","depends_on_id":"opencode-11","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"opencode-11.3","depends_on_id":"opencode-11.2","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]} +{"id":"opencode-11.3.1","title":"Write failing regression test: PVC and Pod share same hash for no-repo session","status":"closed","priority":2,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-05-13T10:23:58.664455+02:00","created_by":"Oliver Jägle","updated_at":"2026-05-13T10:24:22.804951+02:00","closed_at":"2026-05-13T10:24:22.804951+02:00","close_reason":"Closed","dependencies":[{"issue_id":"opencode-11.3.1","depends_on_id":"opencode-11.3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]} +{"id":"opencode-11.3.2","title":"Refactor ensurePVC and ensurePod to accept optional pre-computed hash","status":"closed","priority":2,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-05-13T10:23:59.019326+02:00","created_by":"Oliver Jägle","updated_at":"2026-05-13T10:24:57.659367+02:00","closed_at":"2026-05-13T10:24:57.659367+02:00","close_reason":"Closed","dependencies":[{"issue_id":"opencode-11.3.2","depends_on_id":"opencode-11.3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]} +{"id":"opencode-11.3.3","title":"Expose startSession in pod-manager.ts that calls getSessionHash once","status":"closed","priority":2,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-05-13T10:23:59.233689+02:00","created_by":"Oliver Jägle","updated_at":"2026-05-13T10:24:57.966257+02:00","closed_at":"2026-05-13T10:24:57.966257+02:00","close_reason":"Closed","dependencies":[{"issue_id":"opencode-11.3.3","depends_on_id":"opencode-11.3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]} +{"id":"opencode-11.3.4","title":"Update api.ts to call startSession instead of 3 separate calls","status":"closed","priority":2,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-05-13T10:23:59.451493+02:00","created_by":"Oliver Jägle","updated_at":"2026-05-13T10:25:22.199495+02:00","closed_at":"2026-05-13T10:25:22.199495+02:00","close_reason":"Closed","dependencies":[{"issue_id":"opencode-11.3.4","depends_on_id":"opencode-11.3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]} +{"id":"opencode-11.3.5","title":"Run tests and type check","status":"closed","priority":2,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-05-13T10:23:59.703254+02:00","created_by":"Oliver Jägle","updated_at":"2026-05-13T10:26:01.48793+02:00","closed_at":"2026-05-13T10:26:01.48793+02:00","close_reason":"Closed","dependencies":[{"issue_id":"opencode-11.3.5","depends_on_id":"opencode-11.3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]} +{"id":"opencode-11.4","title":"Verify","description":"Test the fix thoroughly to ensure the original bug is resolved and no new issues were introduced. Run existing tests, create new ones if needed, and verify the solution is robust.","status":"open","priority":3,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-05-13T10:11:45.498051+02:00","created_by":"Oliver Jägle","updated_at":"2026-05-13T10:11:45.498051+02:00","dependencies":[{"issue_id":"opencode-11.4","depends_on_id":"opencode-11","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"opencode-11.4","depends_on_id":"opencode-11.3","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]} +{"id":"opencode-11.5","title":"Finalize","description":"Ensure code quality and documentation accuracy through systematic cleanup and review. **STEP 1: Code Cleanup** Systematically clean up development artifacts: - Remove all temporary debug output statements used during bug investigation (console logging, print statements, debug output functions) - Address each TODO/FIXME comment by either implementing the solution or documenting why it's deferred - Remove completed TODOs and convert remaining ones to proper issue tracking if needed - Remove temporary debugging code, test code blocks, and commented-out code - Ensure proper error handling replaces temporary debug logging **STEP 2: Documentation Review** Review and update documentation to reflect the bug fix: - If exists, update it if design details were refined or changed during the fix - Compare documentation against the actual bug fix implementation - Update only the documentation sections that have functional changes - Remove references to investigation iterations, progress notes, and temporary decisions - Ensure documentation describes the final fixed state, not the debugging process - Ask the user to review document updates **STEP 3: Final Validation** - Run existing tests to ensure cleanup didn't break functionality - Verify documentation accuracy with a final review - Ensure bug fix is ready for production - Update task progress and mark completed work as you finalize the bug fix","status":"open","priority":3,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-05-13T10:11:45.767326+02:00","created_by":"Oliver Jägle","updated_at":"2026-05-13T10:11:45.767326+02:00","dependencies":[{"issue_id":"opencode-11.5","depends_on_id":"opencode-11","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"opencode-11.5","depends_on_id":"opencode-11.4","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]} {"id":"opencode-2","title":"opencode: epcc (development-plan-preconfigure-opencode.md)","description":"Responsible vibe engineering session using epcc workflow for opencode","status":"open","priority":2,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-04-13T10:20:50.855581+02:00","created_by":"Oliver Jägle","updated_at":"2026-04-13T10:20:50.855581+02:00"} {"id":"opencode-2.1","title":"Explore","description":"Research the codebase to understand existing patterns and gather context about the problem space. - If uncertain about conventions or rules, ask the user about them - Read relevant files and documentation - If exists: Understand and document requirements there - Otherwise: Document requirements in your task management system Focus on understanding without writing code yet. Document your findings and create tasks as needed.","status":"closed","priority":3,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-04-13T10:20:51.02768+02:00","created_by":"Oliver Jägle","updated_at":"2026-04-13T10:50:31.197605+02:00","closed_at":"2026-04-13T10:50:31.197605+02:00","close_reason":"Closed","dependencies":[{"issue_id":"opencode-2.1","depends_on_id":"opencode-2","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]} {"id":"opencode-2.1.1","title":"Understand opencode config loading mechanism","status":"closed","priority":2,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-04-13T10:29:16.880116+02:00","created_by":"Oliver Jägle","updated_at":"2026-04-13T10:34:54.667743+02:00","closed_at":"2026-04-13T10:34:54.667743+02:00","close_reason":"Closed","dependencies":[{"issue_id":"opencode-2.1.1","depends_on_id":"opencode-2.1","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]} diff --git a/.beads/last-touched b/.beads/last-touched index 93d3328ec4c4..a356d8be76c9 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -opencode-10.3.5 +opencode-11.3.5 diff --git a/.vibe/beads-state-opencode-fix-stable-hash-session-without-repo-250zb6.json b/.vibe/beads-state-opencode-fix-stable-hash-session-without-repo-250zb6.json new file mode 100644 index 000000000000..94b49c08d53a --- /dev/null +++ b/.vibe/beads-state-opencode-fix-stable-hash-session-without-repo-250zb6.json @@ -0,0 +1,34 @@ +{ + "conversationId": "opencode-fix-stable-hash-session-without-repo-250zb6", + "projectPath": "/Users/oliverjaegle/projects/open-source/opencode", + "epicId": "opencode-11", + "phaseTasks": [ + { + "phaseId": "reproduce", + "phaseName": "Reproduce", + "taskId": "opencode-11.1" + }, + { + "phaseId": "analyze", + "phaseName": "Analyze", + "taskId": "opencode-11.2" + }, + { + "phaseId": "fix", + "phaseName": "Fix", + "taskId": "opencode-11.3" + }, + { + "phaseId": "verify", + "phaseName": "Verify", + "taskId": "opencode-11.4" + }, + { + "phaseId": "finalize", + "phaseName": "Finalize", + "taskId": "opencode-11.5" + } + ], + "createdAt": "2026-05-13T08:11:46.643Z", + "updatedAt": "2026-05-13T08:11:46.643Z" +} \ No newline at end of file diff --git a/.vibe/development-plan-fix-stable-hash-session-without-repo.md b/.vibe/development-plan-fix-stable-hash-session-without-repo.md new file mode 100644 index 000000000000..d679d00008aa --- /dev/null +++ b/.vibe/development-plan-fix-stable-hash-session-without-repo.md @@ -0,0 +1,88 @@ +# Development Plan: opencode (fix/stable-hash-session-without-repo branch) + +*Generated on 2026-05-13 by Vibe Feature MCP* +*Workflow: [bugfix](https://codemcp.github.io/workflows/workflows/bugfix)* + +## Goal +Fix: when creating a new session without a repo, the session hash must be stable — the same hash must be used for the PVC, the Pod, and returned to the client. Currently `getSessionHash` uses `crypto.randomUUID()` for no-repo sessions, so each call produces a different hash, causing the Pod to reference a PVC that doesn't exist. + +## Key Decisions + +### Decision 1: Hash must be purely internal to pod-manager.ts +Exposing the hash on `SessionKey` was rejected because a malicious consumer could pass an arbitrary hash and target another user's PVC. The hash must be computed and frozen **inside** `pod-manager.ts`, never on the public `SessionKey` interface. + +### Decision 2: Introduce an internal `startSession` function in pod-manager.ts +Rather than changing `ensurePVC`/`ensurePod` signatures, expose a single `startSession(session, githubToken?)` function that: +1. Calls `getSessionHash` exactly **once** +2. Passes the frozen hash internally to both `ensurePVC` and `ensurePod` (via a private helper overload or by refactoring to accept a hash internally) +3. Returns the hash to the caller + +`api.ts` calls `startSession` instead of calling `ensurePVC` + `ensurePod` + `getSessionHash` separately. + +### Decision 3: Make hash the first required parameter of `ensurePVC` and `ensurePod` +The hash is now the primary identity input — `ensurePVC(hash, session)` and `ensurePod(hash, session, githubToken?, image?)`. Callers always compute the hash before calling, which makes it impossible to silently get a mismatched hash. The `_hash?: string` optional hack was rejected in favour of this cleaner contract. All callers (both in `pod-manager.ts` and `api.ts`) and all tests were updated accordingly. + +### Decision 5: `SessionKey` stays as the data parameter — repo info IS needed +`ensurePVC` uses `repoUrl`/`branch`/`sourceBranch`/`initialMessage` purely for PVC annotations (so `resumeSession` can reconstruct the session later). `ensurePod` uses `repoUrl`/`branch`/`sourceBranch` functionally to build the correct init script (`git clone` vs `git init`). Removing them from these functions would either require a separate annotation-writing step or duplicate the git-decision logic elsewhere. The `SessionKey` struct is the correct abstraction: **hash = identity, SessionKey = data**. No further simplification needed. + +### Decision 4: `api.ts` git-repo and no-repo paths updated consistently +- **Git-repo path**: still calls `getSessionHash` → `ensurePVC(hash, session)` → `ensurePod(hash, session, githubToken)` directly, since the hash is deterministic. +- **No-repo path**: calls `startSession(session, githubToken)` which freezes the random hash once internally and passes it to both `ensurePVC` and `ensurePod`. +- Both paths now make the hash explicit at the call site, which is the root-cause fix. + +## Notes +*Additional context and observations* + +### Root Cause (confirmed) +- File: `packages/opencode-router/src/pod-manager.ts`, function `getSessionHash` (line ~335) +- For no-repo sessions: `crypto.randomUUID()` is called each time → different hash per call +- In `api.ts` (lines 179–182), `getSessionHash` is called 3 times independently: + - Line 179: `const hash = getSessionHash(email)` → H1 (returned to client) + - Inside `ensurePVC`: `getSessionHash(session.email, ...)` → H2 (used for PVC name) + - Inside `ensurePod`: `getSessionHash(session.email, ...)` → H3 (used for Pod name + PVC claim) +- H1 ≠ H2 ≠ H3 → Pod mounts wrong PVC, client polls wrong hash + +### Why git-repo path is unaffected +- Deterministic hash: `sha256(email:repoUrl:branch)` — same inputs always yield same hash +- Multiple calls produce identical results + +## Reproduce + +### Tasks + +*Auto-synced — do not edit here, use `bd` CLI instead.* + +- [x] `opencode-11.1.1` Confirm: getSessionHash called 3x independently in no-repo path → 3 different hashes + +## Analyze + +### Tasks + +*Auto-synced — do not edit here, use `bd` CLI instead.* + + +## Fix + +### Tasks + +*Auto-synced — do not edit here, use `bd` CLI instead.* + +- [x] `opencode-11.3.1` Write failing regression test: PVC and Pod share same hash for no-repo session +- [x] `opencode-11.3.2` Refactor ensurePVC and ensurePod to accept optional pre-computed hash +- [x] `opencode-11.3.3` Expose startSession in pod-manager.ts that calls getSessionHash once +- [x] `opencode-11.3.4` Update api.ts to call startSession instead of 3 separate calls +- [x] `opencode-11.3.5` Run tests and type check + +## Verify + +### Tasks + +*Auto-synced — do not edit here, use `bd` CLI instead.* + + +## Finalize + +### Tasks + +*Auto-synced — do not edit here, use `bd` CLI instead.* + diff --git a/deployment/homelab/.vibe/conversations/homelab-default-jtldu5/interactions.jsonl b/deployment/homelab/.vibe/conversations/homelab-default-jtldu5/interactions.jsonl index 48df8f25cf98..906b6437c7d7 100644 --- a/deployment/homelab/.vibe/conversations/homelab-default-jtldu5/interactions.jsonl +++ b/deployment/homelab/.vibe/conversations/homelab-default-jtldu5/interactions.jsonl @@ -36,3 +36,4 @@ {"conversationId":"homelab-default-jtldu5","toolName":"whats_next","inputParams":"{}","responseData":"{\"phase\":\"code\",\"instructions\":\"## Code Phase\\n\\nFollow your plan to build the solution:\\n\\n- If `/Users/oliverjaegle/projects/open-source/opencode/deployment/homelab/.vibe/docs/design.md` exists: Follow the design from it\\n- Otherwise: Elaborate design options and present them to the user\\n- If `/Users/oliverjaegle/projects/open-source/opencode/deployment/homelab/.vibe/docs/architecture.md` exists: Build according to the architecture from it\\n- Otherwise: Elaborate architectural options and present them to the user\\n- If `/Users/oliverjaegle/projects/open-source/opencode/deployment/homelab/.vibe/docs/requirements.md` exists: Ensure requirements from it are met\\n- Otherwise: Ensure existing requirements are met based on your task context\\n\\nWrite clean, well-structured code with proper error handling. Prevent regression by building, linting, and executing existing tests. Stay flexible and adapt the plan as you learn more during implementation. Update task progress and create new tasks as needed.\\n\\n\\n---\\n### YOU MUST FOLLOW THESE INSTRUCTIONS:\\n\\n**IMPORTANT: Read `/Users/oliverjaegle/projects/open-source/opencode/deployment/homelab/.vibe/development-plan-default.md`** for context.\\n\\n**ACTION REQUIRED: Focus on \\\"Code\\\" tasks** and log decisions in \\\"Key Decisions\\\"\\n\\n**CRITICAL: Do NOT use other task/todo tools** - use only the plan file for task tracking\\n\\nCall `whats_next()` after user messages.\\n\\nLog decisions in plan file. Use ONLY `bd` CLI for tasks (not your own todo tools).\\n\\n**Task Management (bd CLI) - Phase: opencode-1.3**\\nCreate tasks as sub-tasks: `bd create 'title' --parent opencode-1.3`\\nList open tasks: `bd list --parent opencode-1.3 --status open`\\nComplete tasks: `bd close `\\n\\nCall `whats_next()` after user messages.\",\"plan_file_path\":\"/Users/oliverjaegle/projects/open-source/opencode/deployment/homelab/.vibe/development-plan-default.md\",\"allowed_file_patterns\":[\"**/*\"]}","currentPhase":"code","timestamp":"2026-04-22T15:43:25.167Z"} {"conversationId":"homelab-default-jtldu5","toolName":"whats_next","inputParams":"{}","responseData":"{\"phase\":\"code\",\"instructions\":\"## Code Phase\\n\\nFollow your plan to build the solution:\\n\\n- If `/Users/oliverjaegle/projects/open-source/opencode/deployment/homelab/.vibe/docs/design.md` exists: Follow the design from it\\n- Otherwise: Elaborate design options and present them to the user\\n- If `/Users/oliverjaegle/projects/open-source/opencode/deployment/homelab/.vibe/docs/architecture.md` exists: Build according to the architecture from it\\n- Otherwise: Elaborate architectural options and present them to the user\\n- If `/Users/oliverjaegle/projects/open-source/opencode/deployment/homelab/.vibe/docs/requirements.md` exists: Ensure requirements from it are met\\n- Otherwise: Ensure existing requirements are met based on your task context\\n\\nWrite clean, well-structured code with proper error handling. Prevent regression by building, linting, and executing existing tests. Stay flexible and adapt the plan as you learn more during implementation. Update task progress and create new tasks as needed.\\n\\n\\n---\\n### YOU MUST FOLLOW THESE INSTRUCTIONS:\\n\\n**IMPORTANT: Read `/Users/oliverjaegle/projects/open-source/opencode/deployment/homelab/.vibe/development-plan-default.md`** for context.\\n\\n**ACTION REQUIRED: Focus on \\\"Code\\\" tasks** and log decisions in \\\"Key Decisions\\\"\\n\\n**When all tasks are completed**: Make sure that all insights and decisions are captured in `/Users/oliverjaegle/projects/open-source/opencode/deployment/homelab/.vibe/development-plan-default.md`. Then call proceed_to_phase to move to the next phase.\\n\\n**CRITICAL: Do NOT use other task/todo tools** - use only the plan file for task tracking\\n\\nCall `whats_next()` after user messages.\\n\\nLog decisions in plan file. Use ONLY `bd` CLI for tasks (not your own todo tools).\\n\\n**Task Management (bd CLI) - Phase: opencode-1.3**\\nCreate tasks as sub-tasks: `bd create 'title' --parent opencode-1.3`\\nList open tasks: `bd list --parent opencode-1.3 --status open`\\nComplete tasks: `bd close `\\n\\nCall `whats_next()` after user messages.\",\"plan_file_path\":\"/Users/oliverjaegle/projects/open-source/opencode/deployment/homelab/.vibe/development-plan-default.md\",\"allowed_file_patterns\":[\"**/*\"]}","currentPhase":"code","timestamp":"2026-05-05T13:50:10.272Z"} {"conversationId":"homelab-default-jtldu5","toolName":"whats_next","inputParams":"{}","responseData":"{\"phase\":\"code\",\"instructions\":\"## Code Phase\\n\\nFollow your plan to build the solution:\\n\\n- If `/Users/oliverjaegle/projects/open-source/opencode/deployment/homelab/.vibe/docs/design.md` exists: Follow the design from it\\n- Otherwise: Elaborate design options and present them to the user\\n- If `/Users/oliverjaegle/projects/open-source/opencode/deployment/homelab/.vibe/docs/architecture.md` exists: Build according to the architecture from it\\n- Otherwise: Elaborate architectural options and present them to the user\\n- If `/Users/oliverjaegle/projects/open-source/opencode/deployment/homelab/.vibe/docs/requirements.md` exists: Ensure requirements from it are met\\n- Otherwise: Ensure existing requirements are met based on your task context\\n\\nWrite clean, well-structured code with proper error handling. Prevent regression by building, linting, and executing existing tests. Stay flexible and adapt the plan as you learn more during implementation. Update task progress and create new tasks as needed.\\n\\n\\n---\\n### YOU MUST FOLLOW THESE INSTRUCTIONS:\\n\\n**IMPORTANT: Read `/Users/oliverjaegle/projects/open-source/opencode/deployment/homelab/.vibe/development-plan-default.md`** for context.\\n\\n**ACTION REQUIRED: Focus on \\\"Code\\\" tasks** and log decisions in \\\"Key Decisions\\\"\\n\\n**When all tasks are completed**: Make sure that all insights and decisions are captured in `/Users/oliverjaegle/projects/open-source/opencode/deployment/homelab/.vibe/development-plan-default.md`. Then call proceed_to_phase to move to the next phase.\\n\\n**CRITICAL: Do NOT use other task/todo tools** - use only the plan file for task tracking\\n\\nCall `whats_next()` after user messages.\\n\\nLog decisions in plan file. Use ONLY `bd` CLI for tasks (not your own todo tools).\\n\\n**Task Management (bd CLI) - Phase: opencode-1.3**\\nCreate tasks as sub-tasks: `bd create 'title' --parent opencode-1.3`\\nList open tasks: `bd list --parent opencode-1.3 --status open`\\nComplete tasks: `bd close `\\n\\nCall `whats_next()` after user messages.\",\"plan_file_path\":\"/Users/oliverjaegle/projects/open-source/opencode/deployment/homelab/.vibe/development-plan-default.md\",\"allowed_file_patterns\":[\"**/*\"]}","currentPhase":"code","timestamp":"2026-05-05T13:57:29.212Z"} +{"conversationId":"homelab-default-jtldu5","toolName":"whats_next","inputParams":"{}","responseData":"{\"phase\":\"code\",\"instructions\":\"## Code Phase\\n\\nFollow your plan to build the solution:\\n\\n- If `/Users/oliverjaegle/projects/open-source/opencode/deployment/homelab/.vibe/docs/design.md` exists: Follow the design from it\\n- Otherwise: Elaborate design options and present them to the user\\n- If `/Users/oliverjaegle/projects/open-source/opencode/deployment/homelab/.vibe/docs/architecture.md` exists: Build according to the architecture from it\\n- Otherwise: Elaborate architectural options and present them to the user\\n- If `/Users/oliverjaegle/projects/open-source/opencode/deployment/homelab/.vibe/docs/requirements.md` exists: Ensure requirements from it are met\\n- Otherwise: Ensure existing requirements are met based on your task context\\n\\nWrite clean, well-structured code with proper error handling. Prevent regression by building, linting, and executing existing tests. Stay flexible and adapt the plan as you learn more during implementation. Update task progress and create new tasks as needed.\\n\\n\\n---\\n### YOU MUST FOLLOW THESE INSTRUCTIONS:\\n\\n**IMPORTANT: Read `/Users/oliverjaegle/projects/open-source/opencode/deployment/homelab/.vibe/development-plan-default.md`** for context.\\n\\n**ACTION REQUIRED: Focus on \\\"Code\\\" tasks** and log decisions in \\\"Key Decisions\\\"\\n\\n**When all tasks are completed**: Make sure that all insights and decisions are captured in `/Users/oliverjaegle/projects/open-source/opencode/deployment/homelab/.vibe/development-plan-default.md`. Then call proceed_to_phase to move to the next phase.\\n\\n**CRITICAL: Do NOT use other task/todo tools** - use only the plan file for task tracking\\n\\nCall `whats_next()` after user messages.\\n\\nLog decisions in plan file. Use ONLY `bd` CLI for tasks (not your own todo tools).\\n\\n**Task Management (bd CLI):**\\nCreate tasks as sub-tasks of phase task: `bd create 'title' --parent `\\nList open tasks: `bd list --parent --status open`\\nComplete tasks: `bd close `\\n\\nCall `whats_next()` after user messages.\",\"plan_file_path\":\"/Users/oliverjaegle/projects/open-source/opencode/deployment/homelab/.vibe/development-plan-default.md\",\"allowed_file_patterns\":[\"**/*\"]}","currentPhase":"code","timestamp":"2026-05-13T09:47:53.594Z"} diff --git a/deployment/homelab/.vibe/development-plan-default.md b/deployment/homelab/.vibe/development-plan-default.md new file mode 100644 index 000000000000..21a5fdbdd7c1 --- /dev/null +++ b/deployment/homelab/.vibe/development-plan-default.md @@ -0,0 +1,45 @@ +# Development Plan: homelab (default branch) + +*Generated on 2026-05-13 by Vibe Feature MCP* +*Workflow: [epcc](https://codemcp.github.io/workflows/workflows/epcc)* + +## Goal +*Define what you're building or fixing - this will be updated as requirements are gathered* +## Key Decisions +*Important decisions will be documented here as they are made* + +## Notes +*Additional context and observations* + +## Explore +### Tasks +- [ ] *Tasks will be added as they are identified* + +### Completed +- [x] Created development plan file + +## Plan +### Tasks +- [ ] *To be added when this phase becomes active* + +### Completed +*None yet* + +## Code +### Tasks +- [ ] *To be added when this phase becomes active* + +### Completed +*None yet* + +## Commit +### Tasks +- [ ] *To be added when this phase becomes active* + +### Completed +*None yet* + + + +--- +*This plan is maintained by the LLM. Tool responses provide guidance on which section to focus on and what tasks to work on.* diff --git a/deployment/homelab/images/opencode/config/init-scripts/setup-skills.sh b/deployment/homelab/images/opencode/config/init-scripts/setup-skills.sh index 05cb368df08a..e67f78170824 100644 --- a/deployment/homelab/images/opencode/config/init-scripts/setup-skills.sh +++ b/deployment/homelab/images/opencode/config/init-scripts/setup-skills.sh @@ -1,4 +1,5 @@ #!/bin/sh -# Post-init: register skills from ~/.config/opencode/skills/ with @codemcp/skills-server. -# Runs once after config-init seeds the config directory on first pod start. -npx -y @codemcp/skills experimental_install +# Register skills from ~/.config/opencode/ with @codemcp/skills-server. +# Must cd to the config dir so experimental_install reads the correct skills-lock.json. +# Runs on every pod start (idempotent — no-op for already-installed skills). +cd "$HOME/.config/opencode" && npx -y @codemcp/skills experimental_install --yes diff --git a/packages/opencode-router/src/api.test.ts b/packages/opencode-router/src/api.test.ts index 6615707e64e2..406131f48b2b 100644 --- a/packages/opencode-router/src/api.test.ts +++ b/packages/opencode-router/src/api.test.ts @@ -35,6 +35,7 @@ const mocks = { suggestBranch: mock(() => Promise.resolve("calm-snails-dream")), remoteBranchExists: mock(() => Promise.resolve(true)), prepullImage: mock(() => Promise.resolve(true)), + startSession: mock(() => Promise.resolve("abc123456789")), RemoteRefsUnreachableError, } @@ -318,7 +319,7 @@ describe("POST /api/sessions with sourceBranch", () => { expect(handled).toBe(true) expect(res.statusCode).toBe(201) - const sessionKeyPassed = (mocks.ensurePVC as any).mock.calls[0]?.[0] + const sessionKeyPassed = (mocks.ensurePVC as any).mock.calls[0]?.[1] expect(sessionKeyPassed?.sourceBranch).toBe("main") }) @@ -431,7 +432,7 @@ describe("POST /api/sessions passes githubToken to ensurePod", () => { expect(res.statusCode).toBe(201) expect(mocks.ensurePod).toHaveBeenCalledTimes(1) - expect((mocks.ensurePod as any).mock.calls[0][1]).toBe("gho_test_token") + expect((mocks.ensurePod as any).mock.calls[0][2]).toBe("gho_test_token") }) it("passes undefined to ensurePod when githubToken is absent", async () => { @@ -446,7 +447,7 @@ describe("POST /api/sessions passes githubToken to ensurePod", () => { expect(res.statusCode).toBe(201) expect(mocks.ensurePod).toHaveBeenCalledTimes(1) - expect((mocks.ensurePod as any).mock.calls[0][1]).toBeUndefined() + expect((mocks.ensurePod as any).mock.calls[0][2]).toBeUndefined() }) }) @@ -502,7 +503,7 @@ describe("POST /api/sessions with initialMessage", () => { await handleApi(req as any, res as any, EMAIL) expect(res.statusCode).toBe(201) - const pvcCall = (mocks.ensurePVC as any).mock.calls[0][0] + const pvcCall = (mocks.ensurePVC as any).mock.calls[0][1] expect(pvcCall.initialMessage).toBe("Fix the bug") }) }) @@ -517,6 +518,8 @@ describe("POST /api/sessions — new project (no repoUrl)", () => { mocks.ensurePVC.mockImplementation(() => Promise.resolve()) mocks.ensurePod.mockReset() mocks.ensurePod.mockImplementation(() => Promise.resolve("newproj1234567")) + mocks.startSession.mockReset() + mocks.startSession.mockImplementation(() => Promise.resolve("newproj1234567")) mocks.remoteBranchExists.mockReset() mocks.remoteBranchExists.mockImplementation(() => Promise.resolve(true)) mocks.getSessionHash.mockReset() @@ -550,7 +553,7 @@ describe("POST /api/sessions — new project (no repoUrl)", () => { expect(mocks.remoteBranchExists).not.toHaveBeenCalled() }) - it("passes undefined githubToken to ensurePod when token absent", async () => { + it("passes undefined githubToken to startSession when token absent", async () => { const req = fakeReq("POST", "/api/sessions", { initialMessage: "Build me a new app", }) @@ -558,11 +561,11 @@ describe("POST /api/sessions — new project (no repoUrl)", () => { await handleApi(req as any, res as any, EMAIL) - expect(mocks.ensurePod).toHaveBeenCalledTimes(1) - expect((mocks.ensurePod as any).mock.calls[0][1]).toBeUndefined() + expect(mocks.startSession).toHaveBeenCalledTimes(1) + expect((mocks.startSession as any).mock.calls[0][1]).toBeUndefined() }) - it("passes githubToken to ensurePod for new projects when token present", async () => { + it("passes githubToken to startSession for new projects when token present", async () => { const req = fakeReq("POST", "/api/sessions", { initialMessage: "Build me a new app", }) @@ -570,8 +573,8 @@ describe("POST /api/sessions — new project (no repoUrl)", () => { await handleApi(req as any, res as any, EMAIL, "gho_new_project_token") - expect(mocks.ensurePod).toHaveBeenCalledTimes(1) - expect((mocks.ensurePod as any).mock.calls[0][1]).toBe("gho_new_project_token") + expect(mocks.startSession).toHaveBeenCalledTimes(1) + expect((mocks.startSession as any).mock.calls[0][1]).toBe("gho_new_project_token") }) it("still validates branch when repoUrl is present (backward compat)", async () => { @@ -612,7 +615,7 @@ describe("POST /api/sessions without initialMessage", () => { await handleApi(req as any, res as any, EMAIL) expect(res.statusCode).toBe(201) - const pvcCall = (mocks.ensurePVC as any).mock.calls[0][0] + const pvcCall = (mocks.ensurePVC as any).mock.calls[0][1] expect(pvcCall.initialMessage).toBeUndefined() }) }) @@ -639,7 +642,7 @@ describe("ANNOTATION_INITIAL_MESSAGE in SessionKey — initialMessage passed to await handleApi(req as any, res as any, EMAIL) expect(mocks.ensurePVC).toHaveBeenCalledTimes(1) - const sessionKeyPassed = (mocks.ensurePVC as any).mock.calls[0]?.[0] + const sessionKeyPassed = (mocks.ensurePVC as any).mock.calls[0]?.[1] expect(sessionKeyPassed?.initialMessage).toBe("Hello world") }) }) diff --git a/packages/opencode-router/src/api.ts b/packages/opencode-router/src/api.ts index c4398a788325..cdbc824a2f37 100644 --- a/packages/opencode-router/src/api.ts +++ b/packages/opencode-router/src/api.ts @@ -5,6 +5,7 @@ import { RemoteRefsUnreachableError, ensurePVC, ensurePod, + startSession, getAttachUrl, getPodState, getSessionHash, @@ -166,20 +167,18 @@ export async function handleApi( const session: SessionKey = { email, repoUrl, branch, sourceBranch, initialMessage: initialMessage || undefined } const hash = getSessionHash(email, repoUrl, branch) - await ensurePVC(session) - await ensurePod(session, githubToken) + await ensurePVC(hash, session) + await ensurePod(hash, session, githubToken) sessionsChangedBroadcaster.emit() json(res, 201, { hash, url: null, state: "creating" }) return true } - // New project (blank disc) flow: no git validation needed + // New project (blank disc) flow: no git validation needed. + // startSession freezes the hash once so PVC and Pod share the same identity. const session: SessionKey = { email, initialMessage: initialMessage || undefined } - const hash = getSessionHash(email) - - await ensurePVC(session) - await ensurePod(session, githubToken) + const hash = await startSession(session, githubToken) sessionsChangedBroadcaster.emit() json(res, 201, { hash, url: null, state: "creating" }) diff --git a/packages/opencode-router/src/pod-manager.test.ts b/packages/opencode-router/src/pod-manager.test.ts index 0ad569e5fe84..05e5cd1c3606 100644 --- a/packages/opencode-router/src/pod-manager.test.ts +++ b/packages/opencode-router/src/pod-manager.test.ts @@ -319,8 +319,9 @@ describe("listUserSessions", () => { createPodCalls = [] const { ensurePod } = await import("./pod-manager.js") + const session = { email: EMAIL, repoUrl: REPO, branch: "calm-snails-dream", sourceBranch: "main" } - await (ensurePod as any)({ email: EMAIL, repoUrl: REPO, branch: "calm-snails-dream", sourceBranch: "main" }) + await (ensurePod as any)(computeHash(EMAIL, REPO, "calm-snails-dream"), session) expect(createPodCalls).toHaveLength(1) const pod = (createPodCalls[0] as any).body @@ -523,15 +524,17 @@ describe("ensurePod with githubToken", () => { fakePVCs = [] fakePods = [] const { ensurePod } = await import("./pod-manager.js") + const hash = computeHash(EMAIL, REPO, "calm-snails-dream") await (ensurePod as any)( + hash, { email: EMAIL, repoUrl: REPO, branch: "calm-snails-dream", sourceBranch: "main" }, "gho_test_token", ) expect(createSecretCalls).toHaveLength(1) const secret = (createSecretCalls[0] as any).body - expect(secret.metadata.name).toBe(`opencode-github-${computeHash(EMAIL, REPO, "calm-snails-dream")}`) + expect(secret.metadata.name).toBe(`opencode-github-${hash}`) expect(secret.stringData?.GITHUB_TOKEN).toBe("gho_test_token") }) @@ -541,6 +544,7 @@ describe("ensurePod with githubToken", () => { const hash = computeHash(EMAIL, REPO, "calm-snails-dream") await (ensurePod as any)( + hash, { email: EMAIL, repoUrl: REPO, branch: "calm-snails-dream", sourceBranch: "main" }, "gho_test_token", ) @@ -557,8 +561,10 @@ describe("ensurePod with githubToken", () => { it("init script contains git credential helper setup", async () => { fakePods = [] const { ensurePod } = await import("./pod-manager.js") + const hash = computeHash(EMAIL, REPO, "calm-snails-dream") await (ensurePod as any)( + hash, { email: EMAIL, repoUrl: REPO, branch: "calm-snails-dream", sourceBranch: "main" }, "gho_test_token", ) @@ -573,8 +579,9 @@ describe("ensurePod with githubToken", () => { it("does NOT create a Secret when githubToken is absent", async () => { fakePods = [] const { ensurePod } = await import("./pod-manager.js") + const hash = computeHash(EMAIL, REPO, "calm-snails-dream") - await (ensurePod as any)({ email: EMAIL, repoUrl: REPO, branch: "calm-snails-dream", sourceBranch: "main" }) + await (ensurePod as any)(hash, { email: EMAIL, repoUrl: REPO, branch: "calm-snails-dream", sourceBranch: "main" }) expect(createSecretCalls).toHaveLength(0) }) @@ -584,7 +591,7 @@ describe("ensurePod with githubToken", () => { const { ensurePod } = await import("./pod-manager.js") const hash = computeHash(EMAIL, REPO, "calm-snails-dream") - await (ensurePod as any)({ email: EMAIL, repoUrl: REPO, branch: "calm-snails-dream", sourceBranch: "main" }) + await (ensurePod as any)(hash, { email: EMAIL, repoUrl: REPO, branch: "calm-snails-dream", sourceBranch: "main" }) const pod = (createPodCalls[0] as any).body const mainEnvFrom: any[] = pod.spec.containers[0].envFrom ?? [] @@ -803,11 +810,10 @@ describe("ensurePod injects OPENCODE_POD_SECRET", () => { branch: "test-branch", sourceBranch: "main", } + const hash = getSessionHash(session.email, session.repoUrl, session.branch) const { ensurePod } = await import("./pod-manager.js") - await (ensurePod as any)(session) + await (ensurePod as any)(hash, session) expect(podSecretStoreMock.generate).toHaveBeenCalledTimes(1) - // The hash passed to generate must match getSessionHash output - const hash = getSessionHash(session.email, session.repoUrl, session.branch) expect((podSecretStoreMock.generate as any).mock.calls[0][0]).toBe(hash) }) @@ -819,8 +825,9 @@ describe("ensurePod injects OPENCODE_POD_SECRET", () => { branch: "test-branch", sourceBranch: "main", } + const hash = getSessionHash(session.email, session.repoUrl, session.branch) const { ensurePod } = await import("./pod-manager.js") - await (ensurePod as any)(session) + await (ensurePod as any)(hash, session) const podBody = (createPodCalls[0] as any)?.body const envVars = podBody?.spec?.containers?.[0]?.env ?? [] const secretEnv = envVars.find((e: any) => e.name === "OPENCODE_POD_SECRET") @@ -997,13 +1004,15 @@ describe("ensurePVC — attach password annotation", () => { }) it("stores attach password annotation on new PVC", async () => { - const { ensurePVC } = await import("./pod-manager.js") - await ensurePVC({ + const session = { email: "test@example.com", repoUrl: "https://github.com/x/y", branch: "test-branch", sourceBranch: "main", - }) + } + const hash = computeHash(session.email, session.repoUrl, session.branch) + const { ensurePVC } = await import("./pod-manager.js") + await ensurePVC(hash, session) expect(fakePVCs).toHaveLength(1) const annotations = (fakePVCs[0] as any).metadata?.annotations ?? {} expect(annotations["opencode.ai/attach-password"]).toMatch(/^[a-f0-9]{32}$/) @@ -1031,7 +1040,7 @@ describe("ensurePVC — attach password annotation", () => { }, ] const { ensurePVC } = await import("./pod-manager.js") - await ensurePVC(session) + await ensurePVC(hash, session) // PVC count should still be 1 (not re-created) expect(fakePVCs).toHaveLength(1) const annotations = (fakePVCs[0] as any).metadata?.annotations ?? {} @@ -1371,10 +1380,11 @@ describe("ensurePVC — new project (no repoUrl)", () => { }) it("creates PVC without repo annotations when repoUrl is absent", async () => { - const { ensurePVC } = await import("./pod-manager.js") + const { ensurePVC, getSessionHash: gsh } = await import("./pod-manager.js") const session = { email: EMAIL, initialMessage: "Build a new app" } + const hash = (gsh as any)(EMAIL) - await (ensurePVC as any)(session) + await (ensurePVC as any)(hash, session) expect(fakePVCs).toHaveLength(1) const pvc = fakePVCs[0] as any @@ -1396,26 +1406,28 @@ describe("ensurePod — new project (no repoUrl)", () => { }) it("init script contains git init instead of git clone when repoUrl is absent", async () => { - const { ensurePod } = await import("./pod-manager.js") + const { ensurePod, getSessionHash: gsh } = await import("./pod-manager.js") const session = { email: EMAIL } + const hash = (gsh as any)(EMAIL) - await (ensurePod as any)(session) + await (ensurePod as any)(hash, session) expect(createPodCalls).toHaveLength(1) const pod = (createPodCalls[0] as any).body const script: string = pod.spec.initContainers[0].args[0] - // Must NOT have git clone commands - expect(script).not.toContain("git clone") - // Must have git init - expect(script).toContain("git init /workspace") - expect(script).toContain("git commit -m") + // Must NOT have git clone commands + expect(script).not.toContain("git clone") + // Must have git init (with safe.directory to survive pod restarts) + expect(script).toContain("git -c safe.directory=/workspace init /workspace") + expect(script).toContain("git -c safe.directory=/workspace commit -m") }) it("pod annotations do not include repo annotations when repoUrl is absent", async () => { - const { ensurePod } = await import("./pod-manager.js") + const { ensurePod, getSessionHash: gsh } = await import("./pod-manager.js") const session = { email: EMAIL } + const hash = (gsh as any)(EMAIL) - await (ensurePod as any)(session) + await (ensurePod as any)(hash, session) const pod = (createPodCalls[0] as any).body const ann = pod.metadata?.annotations ?? {} @@ -1428,10 +1440,11 @@ describe("ensurePod — new project (no repoUrl)", () => { }) it("still creates github token Secret when provided, even without repoUrl", async () => { - const { ensurePod } = await import("./pod-manager.js") + const { ensurePod, getSessionHash: gsh } = await import("./pod-manager.js") const session = { email: EMAIL } + const hash = (gsh as any)(EMAIL) - await (ensurePod as any)(session, "gho_new_project_token") + await (ensurePod as any)(hash, session, "gho_new_project_token") expect(createSecretCalls).toHaveLength(1) const secret = (createSecretCalls[0] as any).body @@ -1439,10 +1452,11 @@ describe("ensurePod — new project (no repoUrl)", () => { }) it("init script still contains git credential helper setup when token provided without repoUrl", async () => { - const { ensurePod } = await import("./pod-manager.js") + const { ensurePod, getSessionHash: gsh } = await import("./pod-manager.js") const session = { email: EMAIL } + const hash = (gsh as any)(EMAIL) - await (ensurePod as any)(session, "gho_new_project_token") + await (ensurePod as any)(hash, session, "gho_new_project_token") const pod = (createPodCalls[0] as any).body const script: string = pod.spec.initContainers[0].args[0] @@ -1451,6 +1465,45 @@ describe("ensurePod — new project (no repoUrl)", () => { }) }) +describe("startSession — stable hash for no-repo session", () => { + beforeEach(() => { + fakePVCs = [] + fakePods = [] + createPodCalls = [] + }) + + it("PVC name and Pod claimName share the same hash for a no-repo session", async () => { + const { startSession } = await import("./pod-manager.js") + const session = { email: EMAIL } + + await (startSession as any)(session) + + expect(fakePVCs).toHaveLength(1) + expect(createPodCalls).toHaveLength(1) + + const pvcName: string = (fakePVCs[0] as any).metadata.name + const volumes: any[] = (createPodCalls[0] as any).body.spec.volumes + const claimName: string = volumes.find((v: any) => v.persistentVolumeClaim)?.persistentVolumeClaim?.claimName + + // Both must reference the same hash — extract the hex suffix after "opencode-pvc-" / "opencode-pod-" + const pvcHash = pvcName.replace("opencode-pvc-", "") + const podHash = claimName.replace("opencode-pvc-", "") + expect(pvcHash).toMatch(/^[a-f0-9]{12}$/) + expect(pvcHash).toBe(podHash) + }) + + it("startSession returns the hash used for the PVC", async () => { + const { startSession } = await import("./pod-manager.js") + const session = { email: EMAIL } + + const hash = await (startSession as any)(session) + + expect(hash).toMatch(/^[a-f0-9]{12}$/) + const pvcName: string = (fakePVCs[0] as any).metadata.name + expect(pvcName).toBe(`opencode-pvc-${hash}`) + }) +}) + describe("resumeSession — blank-aware", () => { beforeEach(() => { fakePods = [] @@ -1481,8 +1534,8 @@ describe("resumeSession — blank-aware", () => { expect(createPodCalls).toHaveLength(1) const pod = (createPodCalls[0] as any).body const script: string = pod.spec.initContainers[0].args[0] - // Since no repoUrl in SessionKey, should use git init path - expect(script).toContain("git init /workspace") - expect(script).not.toContain("git clone") + // Since no repoUrl in SessionKey, should use git init path + expect(script).toContain("git -c safe.directory=/workspace init /workspace") + expect(script).not.toContain("git clone") }) }) diff --git a/packages/opencode-router/src/pod-manager.ts b/packages/opencode-router/src/pod-manager.ts index 23a08452dafd..ae2a8a554f46 100644 --- a/packages/opencode-router/src/pod-manager.ts +++ b/packages/opencode-router/src/pod-manager.ts @@ -488,9 +488,10 @@ async function ensureGithubTokenSecret(hash: string, token: string): Promise { - const hash = getSessionHash(session.email, session.repoUrl, session.branch) +export async function ensurePVC(hash: string, session: SessionKey): Promise { const name = pvcName(hash) try { @@ -565,9 +566,8 @@ export async function getPodState(hash: string): Promise { * * @param image - Override the container image (defaults to config.opencodeImage). Used by prepullImage(). */ -export async function ensurePod(session: SessionKey, githubToken?: string, image?: string): Promise { +export async function ensurePod(hash: string, session: SessionKey, githubToken?: string, image?: string): Promise { const containerImage = image ?? config.opencodeImage - const hash = getSessionHash(session.email, session.repoUrl, session.branch) const name = podName(hash) const existingPods = await k8sApi.listNamespacedPod({ @@ -661,7 +661,7 @@ export async function ensurePod(session: SessionKey, githubToken?: string, image ` $GIT checkout -b "${branch}"`, `fi`, ] - : [`git init /workspace`, `cd /workspace`, `git add -A`, `git commit -m "Initial commit" --allow-empty`]), + : [`git -c safe.directory=/workspace init /workspace`, `cd /workspace`, `git -c safe.directory=/workspace add -A`, `git -c safe.directory=/workspace commit -m "Initial commit" --allow-empty`]), ].join("\n") const secCtx: k8s.V1SecurityContext = { @@ -813,6 +813,26 @@ export async function ensurePod(session: SessionKey, githubToken?: string, image return hash } +/** + * Create PVC + Pod for a new session, guaranteeing both use the **same** hash. + * + * For no-repo sessions `getSessionHash` generates a random UUID-based hash on every + * call, so calling `ensurePVC` and `ensurePod` independently would produce different + * hashes (the Pod would reference a PVC that doesn't exist). This function freezes + * the hash once and passes it to both operations. + * + * The hash is kept internal to pod-manager — it must NOT be accepted from untrusted + * callers, to prevent a malicious user from targeting another user's PVC. + * + * @returns The session hash (to be returned to the client and used for polling). + */ +export async function startSession(session: SessionKey, githubToken?: string): Promise { + const hash = getSessionHash(session.email, session.repoUrl, session.branch) + await ensurePVC(hash, session) + await ensurePod(hash, session, githubToken) + return hash +} + /** * Pre-pull a container image by creating a test session, waiting for it to be ready, * then terminating it. This ensures the image is cached on the node for faster cold starts. @@ -837,8 +857,8 @@ export async function prepullImage(image: string, timeoutMs = 300_000): Promise< try { // Create PVC and pod with the new image - await ensurePVC(testSession) - await ensurePod(testSession, undefined, image) + await ensurePVC(hash, testSession) + await ensurePod(hash, testSession, undefined, image) // Poll until pod is running or timeout const deadline = Date.now() + timeoutMs @@ -1111,7 +1131,7 @@ export async function resumeSession(hash: string, email: string, githubToken?: s } if (githubToken) await ensureGithubTokenSecret(hash, githubToken) - await ensurePod(session, githubToken) + await ensurePod(hash, session, githubToken) emitSessionsChanged() }