diff --git a/.github/workflows/daily-sdk-update.yml b/.github/workflows/daily-sdk-update.yml deleted file mode 100644 index 54592302e..000000000 --- a/.github/workflows/daily-sdk-update.yml +++ /dev/null @@ -1,208 +0,0 @@ -name: Daily Claude Agent SDK Update - -on: - schedule: - # Run daily at 8 AM UTC - - cron: '0 8 * * *' - - workflow_dispatch: # Allow manual triggering - -permissions: - contents: write - pull-requests: write - -concurrency: - group: daily-sdk-update - cancel-in-progress: false - -jobs: - update-sdk: - name: Update claude-agent-sdk to latest - if: github.event_name != 'pull_request' - runs-on: ubuntu-latest - timeout-minutes: 15 - - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: main - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Get latest SDK version from PyPI - id: pypi - run: | - LATEST=$(curl -sf --max-time 30 https://pypi.org/pypi/claude-agent-sdk/json | jq -r '.info.version') - - if [ -z "$LATEST" ] || [ "$LATEST" = "null" ]; then - echo "Failed to fetch latest version from PyPI" - exit 1 - fi - - if ! echo "$LATEST" | grep -qE '^[0-9]+(\.[0-9]+)+$'; then - echo "Unexpected version format: $LATEST" - exit 1 - fi - - echo "latest=$LATEST" >> "$GITHUB_OUTPUT" - echo "Latest claude-agent-sdk on PyPI: $LATEST" - - - name: Get current minimum version - id: current - run: | - CURRENT=$(grep 'claude-agent-sdk>=' \ - components/runners/claude-code-runner/pyproject.toml \ - | sed 's/.*>=\([0-9][0-9.]*\).*/\1/') - - if [ -z "$CURRENT" ]; then - echo "Failed to parse current version from pyproject.toml" - exit 1 - fi - - echo "current=$CURRENT" >> "$GITHUB_OUTPUT" - echo "Current minimum version: $CURRENT" - - - name: Check if update is needed - id: check - env: - LATEST: ${{ steps.pypi.outputs.latest }} - CURRENT: ${{ steps.current.outputs.current }} - run: | - # Use version sort — if current sorts last, we are already up to date - NEWEST=$(printf '%s\n%s' "$CURRENT" "$LATEST" | sort -V | tail -1) - - if [ "$NEWEST" = "$CURRENT" ]; then - echo "Already up to date ($CURRENT >= $LATEST)" - echo "needs_update=false" >> "$GITHUB_OUTPUT" - else - echo "Update available: $CURRENT -> $LATEST" - echo "needs_update=true" >> "$GITHUB_OUTPUT" - fi - - - name: Check for existing PR - if: steps.check.outputs.needs_update == 'true' - id: existing_pr - run: | - EXISTING=$(gh pr list \ - --head "auto/update-claude-agent-sdk" \ - --state open \ - --json number \ - --jq 'length') - - if [ "$EXISTING" -gt 0 ]; then - echo "Open PR already exists for SDK update branch" - echo "pr_exists=true" >> "$GITHUB_OUTPUT" - else - echo "pr_exists=false" >> "$GITHUB_OUTPUT" - fi - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Update pyproject.toml - if: steps.check.outputs.needs_update == 'true' && steps.existing_pr.outputs.pr_exists == 'false' - env: - LATEST: ${{ steps.pypi.outputs.latest }} - CURRENT: ${{ steps.current.outputs.current }} - run: | - # Escape dots for sed regex - CURRENT_ESC=$(echo "$CURRENT" | sed 's/\./\\./g') - - sed -i "s/\"claude-agent-sdk>=${CURRENT_ESC}\"/\"claude-agent-sdk>=${LATEST}\"/" \ - components/runners/claude-code-runner/pyproject.toml - - # Verify the update landed - if ! grep -q "claude-agent-sdk>=${LATEST}" components/runners/claude-code-runner/pyproject.toml; then - echo "pyproject.toml was not updated correctly" - exit 1 - fi - - echo "Updated pyproject.toml:" - grep claude-agent-sdk components/runners/claude-code-runner/pyproject.toml - - - name: Install uv - if: steps.check.outputs.needs_update == 'true' && steps.existing_pr.outputs.pr_exists == 'false' - uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0 - with: - enable-cache: true - cache-dependency-glob: components/runners/claude-code-runner/uv.lock - - - name: Regenerate uv.lock - if: steps.check.outputs.needs_update == 'true' && steps.existing_pr.outputs.pr_exists == 'false' - run: | - cd components/runners/claude-code-runner - uv lock - echo "uv.lock regenerated" - - - name: Create branch, commit, and open PR - if: steps.check.outputs.needs_update == 'true' && steps.existing_pr.outputs.pr_exists == 'false' - env: - LATEST: ${{ steps.pypi.outputs.latest }} - CURRENT: ${{ steps.current.outputs.current }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - BRANCH="auto/update-claude-agent-sdk" - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - # Delete remote branch if it exists (leftover from a merged/closed PR) - git push origin --delete "$BRANCH" 2>&1 || echo "Branch $BRANCH did not exist or could not be deleted" - - git checkout -b "$BRANCH" - git add components/runners/claude-code-runner/pyproject.toml \ - components/runners/claude-code-runner/uv.lock - git commit -m "chore(runner): update claude-agent-sdk >=${CURRENT} to >=${LATEST} - - Automated daily update of the Claude Agent SDK minimum version. - - Release notes: https://pypi.org/project/claude-agent-sdk/${LATEST}/" - - git push -u origin "$BRANCH" - - printf '%s\n' \ - "## Summary" \ - "" \ - "- Updates \`claude-agent-sdk\` minimum version from \`>=${CURRENT}\` to \`>=${LATEST}\`" \ - "- Files changed: \`pyproject.toml\` and \`uv.lock\`" \ - "" \ - "## Release Info" \ - "" \ - "PyPI: https://pypi.org/project/claude-agent-sdk/${LATEST}/" \ - "" \ - "## Test Plan" \ - "" \ - "- [ ] Runner tests pass (\`runner-tests\` workflow)" \ - "- [ ] Container image builds successfully (\`components-build-deploy\` workflow)" \ - "" \ - "---" \ - "*Auto-generated by daily-sdk-update workflow*" \ - > /tmp/pr-body.md - - gh pr create \ - --title "chore(runner): update claude-agent-sdk to >=${LATEST}" \ - --body-file /tmp/pr-body.md \ - --base main \ - --head "$BRANCH" - - - name: Summary - if: always() - env: - NEEDS_UPDATE: ${{ steps.check.outputs.needs_update }} - PR_EXISTS: ${{ steps.existing_pr.outputs.pr_exists || 'false' }} - CURRENT: ${{ steps.current.outputs.current }} - LATEST: ${{ steps.pypi.outputs.latest }} - JOB_STATUS: ${{ job.status }} - run: | - if [ "$NEEDS_UPDATE" = "false" ]; then - echo "## No update needed" >> "$GITHUB_STEP_SUMMARY" - echo "claude-agent-sdk \`${CURRENT}\` is already the latest." >> "$GITHUB_STEP_SUMMARY" - elif [ "$PR_EXISTS" = "true" ]; then - echo "## Update PR already exists" >> "$GITHUB_STEP_SUMMARY" - echo "An open PR for branch \`auto/update-claude-agent-sdk\` already exists." >> "$GITHUB_STEP_SUMMARY" - elif [ "$JOB_STATUS" = "failure" ]; then - echo "## Update failed" >> "$GITHUB_STEP_SUMMARY" - echo "Check the logs above for details." >> "$GITHUB_STEP_SUMMARY" - else - echo "## PR created" >> "$GITHUB_STEP_SUMMARY" - echo "claude-agent-sdk \`${CURRENT}\` -> \`${LATEST}\`" >> "$GITHUB_STEP_SUMMARY" - fi diff --git a/.github/workflows/sdk-version-bump.yml b/.github/workflows/sdk-version-bump.yml index c071cb2ec..a985c4d31 100644 --- a/.github/workflows/sdk-version-bump.yml +++ b/.github/workflows/sdk-version-bump.yml @@ -47,8 +47,9 @@ jobs: - name: Check for SDK updates id: check + env: + PACKAGE_ARG: ${{ inputs.package || 'all' }} run: | - PACKAGE_ARG="${{ inputs.package || 'all' }}" python scripts/sdk-version-bump.py --check-only --package "$PACKAGE_ARG" EXIT_CODE=$? @@ -90,16 +91,36 @@ jobs: - name: Apply updates if: steps.check.outputs.updates_available == 'true' id: apply + env: + PACKAGE_ARG: ${{ inputs.package || 'all' }} run: | - PACKAGE_ARG="${{ inputs.package || 'all' }}" python scripts/sdk-version-bump.py --package "$PACKAGE_ARG" # Read outputs PR_TITLE=$(cat .sdk-bump-output/pr-title.txt) echo "pr_title=$PR_TITLE" >> "$GITHUB_OUTPUT" + - name: Check for SDK options drift + if: steps.check.outputs.updates_available == 'true' + id: drift + run: | + pip install claude-agent-sdk + EXIT_CODE=0 + python scripts/sdk-options-drift-check.py || EXIT_CODE=$? + echo "drift_exit=$EXIT_CODE" >> "$GITHUB_OUTPUT" + if [ "$EXIT_CODE" -eq 2 ]; then + echo "::warning::SDK options drift check encountered an error" + elif [ "$EXIT_CODE" -eq 1 ]; then + echo "SDK options drift detected — manifest updated" + fi + - name: Create or update PR if: steps.check.outputs.updates_available == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_TITLE: ${{ steps.apply.outputs.pr_title }} + PR_EXISTS: ${{ steps.existing_pr.outputs.pr_exists }} + PR_NUMBER: ${{ steps.existing_pr.outputs.pr_number }} run: | # Configure git git config user.name "github-actions[bot]" @@ -114,9 +135,20 @@ jobs: git add components/runners/ambient-runner/pyproject.toml git add components/runners/ambient-runner/uv.lock + # Include manifest if drift was detected + if [ -n "$(git diff -- components/runners/ambient-runner/sdk-options-manifest.json)" ]; then + git add components/runners/ambient-runner/sdk-options-manifest.json + { + echo "" + echo "## SDK Options Drift" + echo "" + echo "ClaudeAgentOptions fields changed — sdk-options-manifest.json updated." + echo "Review backend allowlist and frontend schema for new/removed fields." + } >> .sdk-bump-output/pr-body.md + fi + # Commit - PR_TITLE="${{ steps.apply.outputs.pr_title }}" - git commit -m "$PR_TITLE + git commit -m "${PR_TITLE} Automated SDK version bump. @@ -126,12 +158,11 @@ jobs: git push --force origin "$BRANCH" # Create or update PR (use --body-file to avoid arg length limits) - if [ "${{ steps.existing_pr.outputs.pr_exists }}" == "true" ]; then - PR_NUM="${{ steps.existing_pr.outputs.pr_number }}" - gh pr edit "$PR_NUM" \ + if [ "$PR_EXISTS" == "true" ]; then + gh pr edit "$PR_NUMBER" \ --title "$PR_TITLE" \ --body-file .sdk-bump-output/pr-body.md - echo "Updated existing PR #$PR_NUM" + echo "Updated existing PR #$PR_NUMBER" else gh pr create \ --title "$PR_TITLE" \ @@ -141,13 +172,13 @@ jobs: --label "dependencies,automated" echo "Created new PR" fi - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Summary if: always() + env: + UPDATES_AVAILABLE: ${{ steps.check.outputs.updates_available }} run: | - if [ "${{ steps.check.outputs.updates_available }}" == "true" ]; then + if [ "$UPDATES_AVAILABLE" == "true" ]; then { echo "## SDK Version Bump" echo "Updates applied and PR created/updated." diff --git a/components/backend/handlers/sessions.go b/components/backend/handlers/sessions.go index 55df5788d..bb899e4ab 100755 --- a/components/backend/handlers/sessions.go +++ b/components/backend/handlers/sessions.go @@ -66,6 +66,144 @@ var ( ootbCacheTTL = 5 * time.Minute // Cache OOTB workflows for 5 minutes ) +// allowedSdkOptionKeys defines the SDK options that users are allowed to configure. +// Keys NOT in this map are silently dropped by filterSdkOptions. +// Platform-internal keys (cwd, resume, mcp_servers, api_key, etc.) are excluded +// because they are set by the runner, not users. +var allowedSdkOptionKeys = map[string]bool{ + // String keys + "system_prompt": true, + "permission_mode": true, + "effort": true, + "user": true, + // Numeric keys + "max_turns": true, + "max_budget_usd": true, + "max_buffer_size": true, + // Bool keys + "include_partial_messages": true, + "enable_file_checkpointing": true, + // Slice keys + "allowed_tools": true, + "disallowed_tools": true, + "betas": true, + "plugins": true, + // Complex object keys (maps, nested structures) + "thinking": true, + "tools": true, + "sandbox": true, + "output_format": true, + "hooks": true, + "agents": true, + "env": true, + "extra_args": true, +} + +// sdkOptionStringKeys are SDK options that must be string values. +var sdkOptionStringKeys = map[string]bool{ + "permission_mode": true, + "effort": true, + "user": true, +} + +// sdkOptionNumericKeys are SDK options that must be numeric (float64 or int). +var sdkOptionNumericKeys = map[string]bool{ + "max_turns": true, + "max_budget_usd": true, + "max_buffer_size": true, +} + +// sdkOptionBoolKeys are SDK options that must be boolean. +var sdkOptionBoolKeys = map[string]bool{ + "include_partial_messages": true, + "enable_file_checkpointing": true, +} + +// sdkOptionSliceKeys are SDK options that must be slices ([]interface{}). +var sdkOptionSliceKeys = map[string]bool{ + "allowed_tools": true, + "disallowed_tools": true, + "betas": true, + "plugins": true, +} + +// validateSdkOptionValue performs basic type checking on a single SDK option value. +// nil values always pass. Complex object keys (thinking, sandbox, etc.) accept any value. +func validateSdkOptionValue(key string, value interface{}) error { + if value == nil { + return nil + } + + // system_prompt can be string or map (preset format) + if key == "system_prompt" { + switch value.(type) { + case string, map[string]interface{}: + return nil + default: + return fmt.Errorf("sdkOptions.%s must be a string or object, got %T", key, value) + } + } + + if sdkOptionStringKeys[key] { + if _, ok := value.(string); !ok { + return fmt.Errorf("sdkOptions.%s must be a string, got %T", key, value) + } + return nil + } + + if sdkOptionNumericKeys[key] { + switch value.(type) { + case float64, int: + return nil + default: + return fmt.Errorf("sdkOptions.%s must be a number, got %T", key, value) + } + } + + if sdkOptionBoolKeys[key] { + if _, ok := value.(bool); !ok { + return fmt.Errorf("sdkOptions.%s must be a boolean, got %T", key, value) + } + return nil + } + + if sdkOptionSliceKeys[key] { + if _, ok := value.([]interface{}); !ok { + return fmt.Errorf("sdkOptions.%s must be an array, got %T", key, value) + } + return nil + } + + // Complex object keys (thinking, sandbox, output_format, hooks, agents, env, extra_args, tools) + // accept any value — JSON serialization handles them. + return nil +} + +// filterSdkOptions filters an SDK options map, keeping only allowed keys and +// validating primitive types. Unknown keys are silently dropped. Returns nil +// if the input is nil/empty or all keys were filtered out. +func filterSdkOptions(opts map[string]interface{}) (map[string]interface{}, error) { + if len(opts) == 0 { + return nil, nil + } + + filtered := make(map[string]interface{}) + for key, value := range opts { + if !allowedSdkOptionKeys[key] { + continue + } + if err := validateSdkOptionValue(key, value); err != nil { + return nil, err + } + filtered[key] = value + } + + if len(filtered) == 0 { + return nil, nil + } + return filtered, nil +} + // isBinaryContentType checks if a MIME type represents binary content that should be base64 encoded. // This includes images, archives, documents, executables, and other non-text formats. func isBinaryContentType(contentType string) bool { @@ -866,6 +1004,23 @@ func CreateSession(c *gin.Context) { // Note: Operator will delete temp pod when session starts (desired-phase=Running) } + // Process SDK options: filter to allowed keys, validate types, serialize to JSON. + if len(req.SdkOptions) > 0 { + filtered, err := filterSdkOptions(req.SdkOptions) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if filtered != nil { + sdkJSON, err := json.Marshal(filtered) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to serialize sdkOptions: %v", err)}) + return + } + envVars["SDK_OPTIONS"] = string(sdkJSON) + } + } + if len(envVars) > 0 { spec := session["spec"].(map[string]interface{}) // Convert map[string]string to map[string]interface{} for unstructured diff --git a/components/backend/handlers/sessions_sdk_options_test.go b/components/backend/handlers/sessions_sdk_options_test.go new file mode 100644 index 000000000..5d0c9c81e --- /dev/null +++ b/components/backend/handlers/sessions_sdk_options_test.go @@ -0,0 +1,273 @@ +//go:build test + +package handlers + +import ( + test_constants "ambient-code-backend/tests/constants" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("SDK Options", Label(test_constants.LabelUnit, test_constants.LabelHandlers, test_constants.LabelSessions), func() { + + Describe("filterSdkOptions", func() { + It("should pass through valid keys unchanged", func() { + input := map[string]interface{}{ + "system_prompt": "You are helpful", + "max_turns": float64(10), + "max_budget_usd": float64(5.0), + } + result, err := filterSdkOptions(input) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(HaveLen(3)) + Expect(result["system_prompt"]).To(Equal("You are helpful")) + Expect(result["max_turns"]).To(Equal(float64(10))) + Expect(result["max_budget_usd"]).To(Equal(float64(5.0))) + }) + + It("should silently drop unknown keys", func() { + input := map[string]interface{}{ + "system_prompt": "valid", + "unknown_key": "dropped", + "another_unknown": 42, + } + result, err := filterSdkOptions(input) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result).To(HaveKey("system_prompt")) + Expect(result).NotTo(HaveKey("unknown_key")) + Expect(result).NotTo(HaveKey("another_unknown")) + }) + + It("should drop platform-internal keys (cwd, resume, mcp_servers, etc.)", func() { + input := map[string]interface{}{ + "cwd": "/some/path", + "resume": true, + "mcp_servers": []interface{}{}, + "setting_sources": "something", + "continue_conversation": true, + "add_dirs": []interface{}{"/a"}, + "cli_path": "/usr/bin/claude", + "settings": map[string]interface{}{}, + "permission_prompt_tool_name": "tool", + "fork_session": true, + "api_key": "sk-secret", + "stderr": "pipe", + "system_prompt": "valid key", + } + result, err := filterSdkOptions(input) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result).To(HaveKey("system_prompt")) + }) + + It("should return nil for empty map", func() { + result, err := filterSdkOptions(map[string]interface{}{}) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(BeNil()) + }) + + It("should return nil for nil input", func() { + result, err := filterSdkOptions(nil) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(BeNil()) + }) + + It("should return nil when all keys are filtered out", func() { + input := map[string]interface{}{ + "unknown_key": "value", + "api_key": "secret", + } + result, err := filterSdkOptions(input) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(BeNil()) + }) + + It("should return error when a valid key has wrong type", func() { + input := map[string]interface{}{ + "max_turns": "not a number", + } + _, err := filterSdkOptions(input) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("max_turns")) + }) + }) + + Describe("validateSdkOptionValue", func() { + + // --- String keys --- + + Context("string keys (system_prompt, permission_mode, effort, user)", func() { + stringKeys := []string{"permission_mode", "effort", "user"} + + It("should accept string values", func() { + for _, key := range stringKeys { + err := validateSdkOptionValue(key, "valid string") + Expect(err).NotTo(HaveOccurred(), "key=%s should accept string", key) + } + }) + + It("should reject numeric values for string keys", func() { + for _, key := range stringKeys { + err := validateSdkOptionValue(key, float64(42)) + Expect(err).To(HaveOccurred(), "key=%s should reject number", key) + } + }) + }) + + Context("system_prompt (string or map)", func() { + It("should accept a string value", func() { + err := validateSdkOptionValue("system_prompt", "You are helpful") + Expect(err).NotTo(HaveOccurred()) + }) + + It("should accept a map value (preset format)", func() { + preset := map[string]interface{}{ + "type": "preset", + "name": "my-preset", + } + err := validateSdkOptionValue("system_prompt", preset) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should reject a numeric value", func() { + err := validateSdkOptionValue("system_prompt", float64(42)) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("system_prompt")) + }) + }) + + // --- Numeric keys --- + + Context("numeric keys (max_turns, max_budget_usd, max_buffer_size)", func() { + numericKeys := []string{"max_turns", "max_budget_usd", "max_buffer_size"} + + It("should accept float64 values", func() { + for _, key := range numericKeys { + err := validateSdkOptionValue(key, float64(10)) + Expect(err).NotTo(HaveOccurred(), "key=%s should accept float64", key) + } + }) + + It("should accept int values", func() { + for _, key := range numericKeys { + err := validateSdkOptionValue(key, 10) + Expect(err).NotTo(HaveOccurred(), "key=%s should accept int", key) + } + }) + + It("should reject string values for numeric keys", func() { + for _, key := range numericKeys { + err := validateSdkOptionValue(key, "not a number") + Expect(err).To(HaveOccurred(), "key=%s should reject string", key) + } + }) + + It("should reject bool values for numeric keys", func() { + for _, key := range numericKeys { + err := validateSdkOptionValue(key, true) + Expect(err).To(HaveOccurred(), "key=%s should reject bool", key) + } + }) + }) + + // --- Bool keys --- + + Context("bool keys (include_partial_messages, enable_file_checkpointing)", func() { + boolKeys := []string{"include_partial_messages", "enable_file_checkpointing"} + + It("should accept bool values", func() { + for _, key := range boolKeys { + err := validateSdkOptionValue(key, true) + Expect(err).NotTo(HaveOccurred(), "key=%s should accept true", key) + err = validateSdkOptionValue(key, false) + Expect(err).NotTo(HaveOccurred(), "key=%s should accept false", key) + } + }) + + It("should reject string values for bool keys", func() { + for _, key := range boolKeys { + err := validateSdkOptionValue(key, "true") + Expect(err).To(HaveOccurred(), "key=%s should reject string", key) + } + }) + + It("should reject numeric values for bool keys", func() { + for _, key := range boolKeys { + err := validateSdkOptionValue(key, float64(1)) + Expect(err).To(HaveOccurred(), "key=%s should reject number", key) + } + }) + }) + + // --- Slice keys --- + + Context("slice keys (allowed_tools, disallowed_tools, betas, plugins)", func() { + sliceKeys := []string{"allowed_tools", "disallowed_tools", "betas", "plugins"} + + It("should accept []interface{} values", func() { + for _, key := range sliceKeys { + err := validateSdkOptionValue(key, []interface{}{"item1", "item2"}) + Expect(err).NotTo(HaveOccurred(), "key=%s should accept slice", key) + } + }) + + It("should accept empty []interface{}", func() { + for _, key := range sliceKeys { + err := validateSdkOptionValue(key, []interface{}{}) + Expect(err).NotTo(HaveOccurred(), "key=%s should accept empty slice", key) + } + }) + + It("should reject string values for slice keys", func() { + for _, key := range sliceKeys { + err := validateSdkOptionValue(key, "not a slice") + Expect(err).To(HaveOccurred(), "key=%s should reject string", key) + } + }) + }) + + // --- Complex object keys --- + + Context("complex object keys (thinking, sandbox, output_format, hooks, agents, env, extra_args, tools)", func() { + complexKeys := []string{"thinking", "sandbox", "output_format", "hooks", "agents", "env", "extra_args", "tools"} + + It("should accept map[string]interface{} values", func() { + for _, key := range complexKeys { + val := map[string]interface{}{"nested": "value"} + err := validateSdkOptionValue(key, val) + Expect(err).NotTo(HaveOccurred(), "key=%s should accept map", key) + } + }) + + It("should pass through non-map values for complex keys (JSON handles them)", func() { + for _, key := range complexKeys { + // Complex keys accept anything that JSON can serialize + err := validateSdkOptionValue(key, "string-value") + Expect(err).NotTo(HaveOccurred(), "key=%s should pass through string", key) + err = validateSdkOptionValue(key, float64(42)) + Expect(err).NotTo(HaveOccurred(), "key=%s should pass through number", key) + err = validateSdkOptionValue(key, true) + Expect(err).NotTo(HaveOccurred(), "key=%s should pass through bool", key) + } + }) + }) + + // --- nil values --- + + Context("nil values", func() { + It("should always pass validation for any key", func() { + keys := []string{ + "system_prompt", "permission_mode", "max_turns", + "max_budget_usd", "include_partial_messages", + "allowed_tools", "thinking", "effort", "user", + } + for _, key := range keys { + err := validateSdkOptionValue(key, nil) + Expect(err).NotTo(HaveOccurred(), "key=%s should accept nil", key) + } + }) + }) + }) +}) diff --git a/components/backend/types/session.go b/components/backend/types/session.go index 022822c57..a1fcb6bbe 100755 --- a/components/backend/types/session.go +++ b/components/backend/types/session.go @@ -63,21 +63,22 @@ type AgenticSessionStatus struct { } type CreateAgenticSessionRequest struct { - InitialPrompt string `json:"initialPrompt,omitempty"` - DisplayName string `json:"displayName,omitempty"` - RunnerType string `json:"runnerType,omitempty"` - LLMSettings *LLMSettings `json:"llmSettings,omitempty"` - Timeout *int `json:"timeout,omitempty"` - InactivityTimeout *int `json:"inactivityTimeout,omitempty"` - StopOnRunFinished *bool `json:"stopOnRunFinished,omitempty"` - ParentSessionID string `json:"parent_session_id,omitempty"` - Repos []SimpleRepo `json:"repos,omitempty"` - ActiveWorkflow *WorkflowSelection `json:"activeWorkflow,omitempty"` - UserContext *UserContext `json:"userContext,omitempty"` - EnvironmentVariables map[string]string `json:"environmentVariables,omitempty"` - Labels map[string]string `json:"labels,omitempty"` - Annotations map[string]string `json:"annotations,omitempty"` - MCPServers *MCPServersConfig `json:"mcpServers,omitempty"` + InitialPrompt string `json:"initialPrompt,omitempty"` + DisplayName string `json:"displayName,omitempty"` + RunnerType string `json:"runnerType,omitempty"` + LLMSettings *LLMSettings `json:"llmSettings,omitempty"` + Timeout *int `json:"timeout,omitempty"` + InactivityTimeout *int `json:"inactivityTimeout,omitempty"` + StopOnRunFinished *bool `json:"stopOnRunFinished,omitempty"` + ParentSessionID string `json:"parent_session_id,omitempty"` + Repos []SimpleRepo `json:"repos,omitempty"` + ActiveWorkflow *WorkflowSelection `json:"activeWorkflow,omitempty"` + UserContext *UserContext `json:"userContext,omitempty"` + EnvironmentVariables map[string]string `json:"environmentVariables,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` + MCPServers *MCPServersConfig `json:"mcpServers,omitempty"` + SdkOptions map[string]interface{} `json:"sdkOptions,omitempty"` } type CloneSessionRequest struct { diff --git a/components/frontend/src/app/projects/[name]/new/page.tsx b/components/frontend/src/app/projects/[name]/new/page.tsx index b495295db..e0eff53af 100755 --- a/components/frontend/src/app/projects/[name]/new/page.tsx +++ b/components/frontend/src/app/projects/[name]/new/page.tsx @@ -25,6 +25,7 @@ export default function NewSessionPage() { model: string; workflow?: string; repos?: Array<{ url: string; branch?: string; autoPush?: boolean }>; + sdkOptions?: Record; }) => { const workflowConfig = config.workflow === "custom" && customWorkflow ? { gitUrl: customWorkflow.gitUrl, branch: customWorkflow.branch, path: customWorkflow.path } @@ -57,6 +58,7 @@ export default function NewSessionPage() { })), } : {}), + ...(config.sdkOptions ? { sdkOptions: config.sdkOptions } : {}), }, }, { diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/new-session-view.test.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/new-session-view.test.tsx index 86a1c9d8a..df5d64557 100755 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/new-session-view.test.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/new-session-view.test.tsx @@ -40,6 +40,18 @@ vi.mock('../workflow-selector', () => ({ WorkflowSelector: () => , })); +vi.mock('@/hooks/use-local-storage', () => ({ + useLocalStorage: () => [null, vi.fn()], +})); + +vi.mock('@/services/queries/use-feature-flags-admin', () => ({ + useWorkspaceFlag: () => ({ enabled: false, isLoading: false, error: null, source: undefined }), +})); + +vi.mock('@/components/advanced-sdk-options', () => ({ + AdvancedSdkOptions: () => null, +})); + vi.mock('../modals/add-context-modal', () => ({ AddContextModal: ({ onAddRepository }: { open: boolean; onAddRepository: (url: string, branch: string, autoPush?: boolean) => Promise }) => ( <> diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/new-session-view.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/new-session-view.tsx index eada72b71..299b29f99 100755 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/new-session-view.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/new-session-view.tsx @@ -1,10 +1,18 @@ "use client"; import { useState, useRef, useCallback, useEffect, useMemo } from "react"; -import { MessageSquarePlus, ArrowUp, Loader2, Plus, GitBranch, Upload, X } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { MessageSquarePlus, ArrowUp, Loader2, Plus, GitBranch, Upload, X, Settings2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Textarea } from "@/components/ui/textarea"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, @@ -25,6 +33,13 @@ import { useRunnerTypes } from "@/services/queries/use-runner-types"; import { useModels } from "@/services/queries/use-models"; import { DEFAULT_RUNNER_TYPE_ID } from "@/services/api/runner-types"; import { useLocalStorage } from "@/hooks/use-local-storage"; +import { useWorkspaceFlag } from "@/services/queries/use-feature-flags-admin"; +import { AdvancedSdkOptions } from "@/components/advanced-sdk-options"; +import { + claudeAgentOptionsSchema, + claudeAgentOptionsDefaults, + type ClaudeAgentOptionsForm, +} from "@/components/claude-agent-options"; import type { WorkflowConfig } from "../lib/types"; const MENU_VERSION = "2026-04-16"; @@ -44,6 +59,7 @@ type NewSessionViewProps = { model: string; workflow?: string; repos?: Array<{ url: string; branch?: string; autoPush?: boolean }>; + sdkOptions?: Record; }) => void; ootbWorkflows: WorkflowConfig[]; onLoadCustomWorkflow?: () => void; @@ -68,6 +84,14 @@ export function NewSessionView({ } }, [menuSeenVersion, setMenuSeenVersion]); + const { enabled: sdkOptionsEnabled } = useWorkspaceFlag(projectName, "advanced-sdk-options"); + + const sdkOptionsForm = useForm({ + resolver: zodResolver(claudeAgentOptionsSchema), + defaultValues: claudeAgentOptionsDefaults, + }); + const [sdkOptionsDialogOpen, setSdkOptionsDialogOpen] = useState(false); + const [prompt, setPrompt] = useState(""); const [selectedRunner, setSelectedRunner] = useState(DEFAULT_RUNNER_TYPE_ID); const [selectedModel, setSelectedModel] = useState(""); @@ -141,14 +165,30 @@ export function NewSessionView({ // Require either a prompt OR a workflow with startupPrompt if (!trimmed && !hasWorkflow) return; + // SDK options are only sent when the user explicitly saved them via the + // AdvancedSdkOptions component. The component filters out defaults and + // empty values internally. We check the form's dirty state here to see + // if any non-default options were committed. + const rawOpts = sdkOptionsForm.getValues(); + const sdkOptions: Record = {}; + const defaults = claudeAgentOptionsDefaults as Record; + for (const [key, value] of Object.entries(rawOpts)) { + if (value === undefined || value === "" || value === null) continue; + if (Array.isArray(value) && value.length === 0) continue; + if (typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 0) continue; + if (key in defaults && JSON.stringify(value) === JSON.stringify(defaults[key])) continue; + sdkOptions[key] = value; + } + onCreateSession({ prompt: trimmed, runner: selectedRunner, model: selectedModel, workflow: hasWorkflow ? selectedWorkflow : undefined, repos: pendingRepos.length > 0 ? pendingRepos.map((r) => ({ url: r.url, branch: r.branch, autoPush: r.autoPush })) : undefined, + sdkOptions: Object.keys(sdkOptions).length > 0 ? sdkOptions : undefined, }); - }, [prompt, selectedRunner, selectedModel, selectedWorkflow, pendingRepos, onCreateSession]); + }, [prompt, selectedRunner, selectedModel, selectedWorkflow, pendingRepos, sdkOptionsForm, onCreateSession]); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { @@ -208,7 +248,15 @@ export function NewSessionView({ Upload File - + {sdkOptionsEnabled && ( + <> + + setSdkOptionsDialogOpen(true)}> + + SDK Options... + + + )} + {/* SDK Options Dialog (opened from + menu) */} + + + + SDK Options + + setSdkOptionsDialogOpen(false)} + /> + + + { + const actual = await vi.importActual("../claude-agent-options"); + return { + ...actual, + AgentOptionsFields: ({ disabled }: { disabled?: boolean }) => ( +
+ Agent Options Fields +
+ ), + }; +}); + +function renderWithForm(props?: { disabled?: boolean; onSave?: () => void }) { + const onSave = props?.onSave ?? vi.fn(); + function TestHarness() { + const form = useForm({ + resolver: zodResolver(claudeAgentOptionsSchema), + defaultValues: claudeAgentOptionsDefaults, + }); + return ( + + ); + } + return { ...render(), onSave }; +} + +describe("AdvancedSdkOptions", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders form fields and save/cancel buttons", () => { + renderWithForm(); + expect(screen.getByTestId("agent-options-fields")).toBeDefined(); + expect(screen.getByText("Save Options")).toBeDefined(); + expect(screen.getByText("Cancel")).toBeDefined(); + }); + + it("calls onSave when Save Options is clicked", () => { + const { onSave } = renderWithForm(); + fireEvent.click(screen.getByText("Save Options")); + expect(onSave).toHaveBeenCalled(); + }); + + it("calls onSave when Cancel is clicked with no changes", () => { + const { onSave } = renderWithForm(); + fireEvent.click(screen.getByText("Cancel")); + expect(onSave).toHaveBeenCalled(); + }); + + it("disables buttons when disabled prop is true", () => { + renderWithForm({ disabled: true }); + const saveBtn = screen.getByText("Save Options").closest("button"); + expect(saveBtn?.hasAttribute("disabled")).toBe(true); + }); +}); diff --git a/components/frontend/src/components/advanced-sdk-options.tsx b/components/frontend/src/components/advanced-sdk-options.tsx new file mode 100644 index 000000000..99f2f7b97 --- /dev/null +++ b/components/frontend/src/components/advanced-sdk-options.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useState, useCallback } from "react"; +import type { UseFormReturn } from "react-hook-form"; +import { Check, X } from "lucide-react"; +import { Form } from "@/components/ui/form"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + AgentOptionsFields, + claudeAgentOptionsDefaults, + type ClaudeAgentOptionsForm, +} from "./claude-agent-options"; + +type AdvancedSdkOptionsProps = { + projectName: string; + form: UseFormReturn; + disabled?: boolean; + onSave?: () => void; +}; + +export function AdvancedSdkOptions({ + form, + disabled, + onSave, +}: AdvancedSdkOptionsProps) { + const [showAbandonDialog, setShowAbandonDialog] = useState(false); + const [snapshotValues, setSnapshotValues] = + useState | null>(null); + + const takeSnapshot = useCallback(() => { + if (!snapshotValues) { + setSnapshotValues(form.getValues()); + } + }, [form, snapshotValues]); + + const isDirty = useCallback(() => { + const current = form.getValues(); + const compare = snapshotValues ?? claudeAgentOptionsDefaults; + return JSON.stringify(current) !== JSON.stringify(compare); + }, [form, snapshotValues]); + + const handleSave = useCallback(() => { + setSnapshotValues(form.getValues()); + onSave?.(); + }, [form, onSave]); + + const handleCancel = useCallback(() => { + if (isDirty()) { + setShowAbandonDialog(true); + } else { + onSave?.(); + } + }, [isDirty, onSave]); + + const handleAbandon = useCallback(() => { + if (snapshotValues) { + form.reset(snapshotValues as ClaudeAgentOptionsForm); + } else { + form.reset(claudeAgentOptionsDefaults as ClaudeAgentOptionsForm); + } + setShowAbandonDialog(false); + onSave?.(); + }, [form, snapshotValues, onSave]); + + const handleSaveFromDialog = useCallback(() => { + handleSave(); + setShowAbandonDialog(false); + }, [handleSave]); + + // Take snapshot on first render inside the dialog + takeSnapshot(); + + return ( + <> +
+
+ + +
+ + +
+
+ + {/* Dirty form guard */} + + + + Unsaved SDK Options + + You have unsaved changes to the SDK options. Save them or discard? + + + + + + + + + + ); +} diff --git a/components/frontend/src/components/create-session-dialog.tsx b/components/frontend/src/components/create-session-dialog.tsx index 5e9101285..a1650d01c 100755 --- a/components/frontend/src/components/create-session-dialog.tsx +++ b/components/frontend/src/components/create-session-dialog.tsx @@ -230,7 +230,7 @@ export function CreateSessionDialog({ } if (advancedAgentOptions) { - request.agentOptions = agentOptionsForm.getValues(); + request.sdkOptions = agentOptionsForm.getValues(); } createSessionMutation.mutate( diff --git a/components/frontend/src/types/agentic-session.ts b/components/frontend/src/types/agentic-session.ts index 9795ea23f..fa11cf12e 100755 --- a/components/frontend/src/types/agentic-session.ts +++ b/components/frontend/src/types/agentic-session.ts @@ -257,8 +257,8 @@ export type CreateAgenticSessionRequest = { mcpServers?: MCPServersConfig; // TODO: Backend handler must unmarshal this field and write it into the // AgenticSession CR spec. Until then, Go encoding/json silently drops it. - // Safe while the `advanced-agent-options` Unleash flag defaults to off. - agentOptions?: Record; + // Safe while the `advanced-sdk-options` Unleash flag defaults to off. + sdkOptions?: Record; }; export type AgentPersona = { diff --git a/components/frontend/src/types/api/sessions.ts b/components/frontend/src/types/api/sessions.ts index 38076d316..80f22b65c 100644 --- a/components/frontend/src/types/api/sessions.ts +++ b/components/frontend/src/types/api/sessions.ts @@ -145,8 +145,8 @@ export type CreateAgenticSessionRequest = { // The frontend validates via ClaudeAgentOptionsForm (Zod schema) before sending. // TODO: Backend handler in components/backend/ must unmarshal this field and write // it into the AgenticSession CR spec. Until then, Go encoding/json silently drops - // the field. Safe while the `advanced-agent-options` flag defaults to off. - agentOptions?: Record; + // the field. Safe while the `advanced-sdk-options` flag defaults to off. + sdkOptions?: Record; }; export type CreateAgenticSessionResponse = { diff --git a/components/manifests/base/core/flags.json b/components/manifests/base/core/flags.json index 6c321b408..43e169fcb 100644 --- a/components/manifests/base/core/flags.json +++ b/components/manifests/base/core/flags.json @@ -39,6 +39,16 @@ "value": "workspace" } ] + }, + { + "name": "advanced-sdk-options", + "description": "Show Advanced SDK Options in session creation UI", + "tags": [ + { + "type": "scope", + "value": "workspace" + } + ] } ] } diff --git a/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py b/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py index ee4ef1174..d2d5b4c54 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py @@ -9,6 +9,7 @@ - Interrupt and graceful shutdown """ +import json import logging import os import time @@ -39,6 +40,89 @@ # Maximum stderr lines kept in ring buffer for error reporting _MAX_STDERR_LINES = 50 +# Keys the platform controls — user SDK_OPTIONS cannot override these. +_SDK_OPTIONS_DENYLIST = frozenset( + { + "cwd", + "resume", + "mcp_servers", + "setting_sources", + "stderr", + "continue_conversation", + "add_dirs", + "api_key", + "cli_path", + "env", + } +) + + +def _parse_sdk_options( + raw: str, + existing_system_prompt: str | dict | None = None, +) -> dict[str, Any]: + """Parse the SDK_OPTIONS JSON string and return filtered options. + + - Empty/whitespace input returns ``{}``. + - Invalid JSON logs a warning and returns ``{}``. + - Non-object JSON (e.g. array) logs a warning and returns ``{}``. + - Denylisted keys are dropped with per-key warnings. + - ``system_prompt`` (truthy string) is merged into the existing + platform prompt under a ``## Custom Instructions`` heading. + - ``None`` values are silently dropped. + """ + if not raw or not raw.strip(): + return {} + + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + logger.warning("SDK_OPTIONS contains invalid JSON, ignoring: %s", exc) + return {} + + if not isinstance(parsed, dict): + logger.warning( + "SDK_OPTIONS must be a JSON object, got %s — ignoring", + type(parsed).__name__, + ) + return {} + + result: dict[str, Any] = {} + for key, value in parsed.items(): + if key in _SDK_OPTIONS_DENYLIST: + logger.warning("SDK_OPTIONS key '%s' is denied — skipping", key) + continue + + if key == "system_prompt": + if not value or not isinstance(value, str) or not value.strip(): + continue + # Merge into existing system prompt + suffix = f"\n\n## Custom Instructions\n{value}" + if isinstance(existing_system_prompt, dict): + merged = dict(existing_system_prompt) + if "append" in merged: + merged["append"] = merged["append"] + suffix + elif "text" in merged: + merged["text"] = merged["text"] + suffix + else: + # Unknown dict shape — add an "append" field + merged["append"] = suffix + result["system_prompt"] = merged + elif isinstance(existing_system_prompt, str): + result["system_prompt"] = existing_system_prompt + suffix + else: + # No existing prompt — use the custom instructions directly + result["system_prompt"] = f"## Custom Instructions\n{value}" + continue + + if value is not None: + result[key] = value + + if result: + logger.info("Applied %d SDK option(s) from SDK_OPTIONS", len(result)) + + return result + class ClaudeBridge(PlatformBridge): """Bridge between the Ambient platform and the Claude Agent SDK. @@ -635,6 +719,15 @@ def _stderr_handler(line: str) -> None: if self._configured_model: options["model"] = self._configured_model + # Apply user SDK_OPTIONS (from CR env vars) with denylist filtering + sdk_options_raw = os.getenv("SDK_OPTIONS", "") + if sdk_options_raw: + user_opts = _parse_sdk_options( + sdk_options_raw, + existing_system_prompt=options.get("system_prompt"), + ) + options.update(user_opts) + adapter = ClaudeAgentAdapter( name="claude_code_runner", description="Ambient Code Platform Claude session", diff --git a/components/runners/ambient-runner/sdk-options-manifest.json b/components/runners/ambient-runner/sdk-options-manifest.json new file mode 100644 index 000000000..0a4a1f1f8 --- /dev/null +++ b/components/runners/ambient-runner/sdk-options-manifest.json @@ -0,0 +1,164 @@ +{ + "description": "Canonical list of Claude Agent SDK ClaudeAgentOptions fields", + "generatedFrom": "claude-agent-sdk (PyPI)", + "generatedAt": "2026-04-16T04:32:37.832878+00:00", + "sdkVersion": "0.1.59", + "options": { + "tools": { + "type": "list[str] | claude_agent_sdk.types.ToolsPreset | None", + "required": false + }, + "allowed_tools": { + "type": "list[str]", + "required": false + }, + "system_prompt": { + "type": "str | claude_agent_sdk.types.SystemPromptPreset | claude_agent_sdk.types.SystemPromptFile | None", + "required": false + }, + "mcp_servers": { + "type": "dict[str, claude_agent_sdk.types.McpStdioServerConfig | claude_agent_sdk.types.McpSSEServerConfig | claude_agent_sdk.types.McpHttpServerConfig | claude_agent_sdk.types.McpSdkServerConfig] | str | pathlib._local.Path", + "required": false + }, + "permission_mode": { + "type": "typing.Optional[typing.Literal['default', 'acceptEdits', 'plan', 'bypassPermissions', 'dontAsk', 'auto']]", + "required": false + }, + "continue_conversation": { + "type": "", + "required": false + }, + "resume": { + "type": "str | None", + "required": false + }, + "session_id": { + "type": "str | None", + "required": false + }, + "max_turns": { + "type": "int | None", + "required": false + }, + "max_budget_usd": { + "type": "float | None", + "required": false + }, + "disallowed_tools": { + "type": "list[str]", + "required": false + }, + "model": { + "type": "str | None", + "required": false + }, + "fallback_model": { + "type": "str | None", + "required": false + }, + "betas": { + "type": "list[typing.Literal['context-1m-2025-08-07']]", + "required": false + }, + "permission_prompt_tool_name": { + "type": "str | None", + "required": false + }, + "cwd": { + "type": "str | pathlib._local.Path | None", + "required": false + }, + "cli_path": { + "type": "str | pathlib._local.Path | None", + "required": false + }, + "settings": { + "type": "str | None", + "required": false + }, + "add_dirs": { + "type": "list[str | pathlib._local.Path]", + "required": false + }, + "env": { + "type": "dict[str, str]", + "required": false + }, + "extra_args": { + "type": "dict[str, str | None]", + "required": false + }, + "max_buffer_size": { + "type": "int | None", + "required": false + }, + "debug_stderr": { + "type": "typing.Any", + "required": false + }, + "stderr": { + "type": "collections.abc.Callable[[str], None] | None", + "required": false + }, + "can_use_tool": { + "type": "collections.abc.Callable[[str, dict[str, typing.Any], claude_agent_sdk.types.ToolPermissionContext], collections.abc.Awaitable[claude_agent_sdk.types.PermissionResultAllow | claude_agent_sdk.types.PermissionResultDeny]] | None", + "required": false + }, + "hooks": { + "type": "dict[typing.Union[typing.Literal['PreToolUse'], typing.Literal['PostToolUse'], typing.Literal['PostToolUseFailure'], typing.Literal['UserPromptSubmit'], typing.Literal['Stop'], typing.Literal['SubagentStop'], typing.Literal['PreCompact'], typing.Literal['Notification'], typing.Literal['SubagentStart'], typing.Literal['PermissionRequest']], list[claude_agent_sdk.types.HookMatcher]] | None", + "required": false + }, + "user": { + "type": "str | None", + "required": false + }, + "include_partial_messages": { + "type": "", + "required": false + }, + "fork_session": { + "type": "", + "required": false + }, + "agents": { + "type": "dict[str, claude_agent_sdk.types.AgentDefinition] | None", + "required": false + }, + "setting_sources": { + "type": "list[typing.Literal['user', 'project', 'local']] | None", + "required": false + }, + "sandbox": { + "type": "claude_agent_sdk.types.SandboxSettings | None", + "required": false + }, + "plugins": { + "type": "list[claude_agent_sdk.types.SdkPluginConfig]", + "required": false + }, + "max_thinking_tokens": { + "type": "int | None", + "required": false + }, + "thinking": { + "type": "claude_agent_sdk.types.ThinkingConfigAdaptive | claude_agent_sdk.types.ThinkingConfigEnabled | claude_agent_sdk.types.ThinkingConfigDisabled | None", + "required": false + }, + "effort": { + "type": "typing.Optional[typing.Literal['low', 'medium', 'high', 'max']]", + "required": false + }, + "output_format": { + "type": "dict[str, typing.Any] | None", + "required": false + }, + "enable_file_checkpointing": { + "type": "", + "required": false + }, + "task_budget": { + "type": "claude_agent_sdk.types.TaskBudget | None", + "required": false + } + } +} diff --git a/components/runners/ambient-runner/tests/test_sdk_options.py b/components/runners/ambient-runner/tests/test_sdk_options.py new file mode 100644 index 000000000..77d5f8fb6 --- /dev/null +++ b/components/runners/ambient-runner/tests/test_sdk_options.py @@ -0,0 +1,289 @@ +"""Tests for SDK_OPTIONS env var parsing in ClaudeBridge._ensure_adapter.""" + +import json +import logging +import os +from typing import Any +from unittest.mock import patch + +import pytest + +from ambient_runner.bridges.claude.bridge import ( + ClaudeBridge, + _SDK_OPTIONS_DENYLIST, + _parse_sdk_options, +) + + +# ------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------ + +ENV_KEY = "SDK_OPTIONS" + + +def _make_bridge(**overrides: Any) -> ClaudeBridge: + """Create a ClaudeBridge with minimal state so _ensure_adapter() can run.""" + bridge = ClaudeBridge() + bridge._cwd_path = overrides.get("cwd_path", "/workspace") + bridge._allowed_tools = overrides.get("allowed_tools", []) + bridge._mcp_servers = overrides.get("mcp_servers", {}) + bridge._system_prompt = overrides.get( + "system_prompt", {"type": "preset", "preset": "claude_code", "append": "base"} + ) + bridge._add_dirs = overrides.get("add_dirs", []) + bridge._configured_model = overrides.get("configured_model", "") + return bridge + + +# ------------------------------------------------------------------ +# _parse_sdk_options unit tests +# ------------------------------------------------------------------ + + +class TestParseSdkOptionsValidJson: + """Valid JSON from SDK_OPTIONS env var is parsed into a dict.""" + + def test_valid_json_returns_dict(self): + raw = json.dumps({"max_tokens": 4096, "temperature": 0.5}) + result = _parse_sdk_options(raw) + assert result == {"max_tokens": 4096, "temperature": 0.5} + + def test_empty_string_returns_empty_dict(self): + assert _parse_sdk_options("") == {} + + def test_whitespace_only_returns_empty_dict(self): + assert _parse_sdk_options(" ") == {} + + +class TestParseSdkOptionsMalformedJson: + """Malformed JSON (not valid JSON) logs a warning and returns empty dict.""" + + def test_malformed_json_returns_empty(self, caplog): + with caplog.at_level(logging.WARNING): + result = _parse_sdk_options("{not valid json") + assert result == {} + assert any( + "SDK_OPTIONS" in r.message and "invalid JSON" in r.message + for r in caplog.records + ) + + def test_trailing_comma_returns_empty(self, caplog): + with caplog.at_level(logging.WARNING): + result = _parse_sdk_options('{"a": 1,}') + assert result == {} + + +class TestParseSdkOptionsJsonArray: + """JSON array (valid JSON but not object) logs a warning and returns empty dict.""" + + def test_json_array_returns_empty(self, caplog): + with caplog.at_level(logging.WARNING): + result = _parse_sdk_options("[1, 2, 3]") + assert result == {} + assert any("must be a JSON object" in r.message for r in caplog.records) + + def test_json_string_returns_empty(self, caplog): + with caplog.at_level(logging.WARNING): + result = _parse_sdk_options('"just a string"') + assert result == {} + + +class TestParseSdkOptionsDenylist: + """Denylisted keys are blocked with per-key warning.""" + + @pytest.mark.parametrize("key", sorted(_SDK_OPTIONS_DENYLIST)) + def test_denylisted_key_blocked(self, key, caplog): + raw = json.dumps({key: "some_value"}) + with caplog.at_level(logging.WARNING): + result = _parse_sdk_options(raw) + assert key not in result + assert any(key in r.message and "denied" in r.message for r in caplog.records) + + def test_all_denylisted_keys_present(self): + """Verify the denylist contains expected keys.""" + expected = { + "cwd", + "api_key", + "mcp_servers", + "setting_sources", + "stderr", + "resume", + "continue_conversation", + "add_dirs", + "cli_path", + "env", + } + assert _SDK_OPTIONS_DENYLIST == expected + + def test_mixed_allowed_and_denied(self, caplog): + raw = json.dumps({"temperature": 0.5, "cwd": "/bad", "max_tokens": 100}) + with caplog.at_level(logging.WARNING): + result = _parse_sdk_options(raw) + assert result == {"temperature": 0.5, "max_tokens": 100} + assert "cwd" not in result + + +class TestParseSdkOptionsPassthrough: + """Non-denylisted keys pass through.""" + + def test_allowed_keys_pass_through(self): + raw = json.dumps( + { + "temperature": 0.7, + "max_tokens": 8192, + "model": "claude-sonnet-4-20250514", + } + ) + result = _parse_sdk_options(raw) + assert result == { + "temperature": 0.7, + "max_tokens": 8192, + "model": "claude-sonnet-4-20250514", + } + + def test_none_value_excluded(self): + raw = json.dumps({"temperature": None}) + result = _parse_sdk_options(raw) + assert "temperature" not in result + + def test_count_logged_at_info(self, caplog): + raw = json.dumps({"temperature": 0.5, "max_tokens": 100}) + with caplog.at_level(logging.INFO): + _parse_sdk_options(raw) + assert any("2 SDK option(s)" in r.message for r in caplog.records) + + +class TestParseSdkOptionsSystemPromptString: + """system_prompt string value is appended to platform prompt under Custom Instructions heading.""" + + def test_system_prompt_appended_to_string_prompt(self): + raw = json.dumps({"system_prompt": "Always respond in French"}) + result = _parse_sdk_options(raw, existing_system_prompt="You are helpful.") + assert result["system_prompt"] == ( + "You are helpful.\n\n## Custom Instructions\nAlways respond in French" + ) + + def test_system_prompt_appended_to_dict_prompt_with_append_field(self): + existing = {"type": "preset", "preset": "claude_code", "append": "base prompt"} + raw = json.dumps({"system_prompt": "Use Python 3.12"}) + result = _parse_sdk_options(raw, existing_system_prompt=existing) + expected = dict(existing) + expected["append"] = "base prompt\n\n## Custom Instructions\nUse Python 3.12" + assert result["system_prompt"] == expected + + def test_system_prompt_dict_with_text_field(self): + existing = {"text": "You are a code reviewer."} + raw = json.dumps({"system_prompt": "Focus on security"}) + result = _parse_sdk_options(raw, existing_system_prompt=existing) + expected = dict(existing) + expected["text"] = ( + "You are a code reviewer.\n\n## Custom Instructions\nFocus on security" + ) + assert result["system_prompt"] == expected + + +class TestParseSdkOptionsSystemPromptIgnored: + """system_prompt as None/empty is ignored (platform prompt unchanged).""" + + def test_system_prompt_none_ignored(self): + raw = json.dumps({"system_prompt": None}) + result = _parse_sdk_options(raw, existing_system_prompt="base") + assert "system_prompt" not in result + + def test_system_prompt_empty_string_ignored(self): + raw = json.dumps({"system_prompt": ""}) + result = _parse_sdk_options(raw, existing_system_prompt="base") + assert "system_prompt" not in result + + def test_system_prompt_whitespace_ignored(self): + raw = json.dumps({"system_prompt": " "}) + result = _parse_sdk_options(raw, existing_system_prompt="base") + assert "system_prompt" not in result + + +# ------------------------------------------------------------------ +# Integration: _ensure_adapter applies SDK_OPTIONS +# ------------------------------------------------------------------ + + +class TestEnsureAdapterSdkOptions: + """Verify _ensure_adapter integrates _parse_sdk_options into the adapter options.""" + + def test_sdk_options_applied_to_adapter(self): + bridge = _make_bridge() + env = {ENV_KEY: json.dumps({"temperature": 0.3, "max_tokens": 2048})} + + with ( + patch.dict(os.environ, env, clear=False), + patch( + "ambient_runner.bridges.claude.bridge.ClaudeAgentAdapter" + ) as mock_adapter_cls, + ): + bridge._ensure_adapter() + + call_kwargs = mock_adapter_cls.call_args[1] + opts = call_kwargs["options"] + assert opts["temperature"] == 0.3 + assert opts["max_tokens"] == 2048 + + def test_sdk_options_denylisted_key_not_in_adapter(self): + bridge = _make_bridge() + env = {ENV_KEY: json.dumps({"cwd": "/evil", "temperature": 0.5})} + + with ( + patch.dict(os.environ, env, clear=False), + patch( + "ambient_runner.bridges.claude.bridge.ClaudeAgentAdapter" + ) as mock_adapter_cls, + ): + bridge._ensure_adapter() + + call_kwargs = mock_adapter_cls.call_args[1] + opts = call_kwargs["options"] + # cwd should remain the bridge's own value, not overridden + assert opts["cwd"] == "/workspace" + assert opts["temperature"] == 0.5 + + def test_sdk_options_system_prompt_merged(self): + bridge = _make_bridge( + system_prompt={ + "type": "preset", + "preset": "claude_code", + "append": "platform base", + } + ) + env = {ENV_KEY: json.dumps({"system_prompt": "Be concise"})} + + with ( + patch.dict(os.environ, env, clear=False), + patch( + "ambient_runner.bridges.claude.bridge.ClaudeAgentAdapter" + ) as mock_adapter_cls, + ): + bridge._ensure_adapter() + + call_kwargs = mock_adapter_cls.call_args[1] + opts = call_kwargs["options"] + assert "## Custom Instructions" in opts["system_prompt"]["append"] + assert "Be concise" in opts["system_prompt"]["append"] + + def test_no_sdk_options_env_var(self): + bridge = _make_bridge() + env = {} + + with ( + patch.dict(os.environ, env, clear=False), + patch( + "ambient_runner.bridges.claude.bridge.ClaudeAgentAdapter" + ) as mock_adapter_cls, + ): + # Ensure SDK_OPTIONS is not set + os.environ.pop(ENV_KEY, None) + bridge._ensure_adapter() + + call_kwargs = mock_adapter_cls.call_args[1] + opts = call_kwargs["options"] + # Should have base options only + assert opts["cwd"] == "/workspace" + assert "temperature" not in opts diff --git a/scripts/sdk-options-drift-check.py b/scripts/sdk-options-drift-check.py new file mode 100755 index 000000000..61cef997c --- /dev/null +++ b/scripts/sdk-options-drift-check.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Detect drift between the installed claude-agent-sdk and the committed manifest. + +Exit codes: + 0 - No drift detected + 1 - Drift detected (manifest updated in-place) + 2 - Error (import failure, missing file, etc.) +""" + +from __future__ import annotations + +import dataclasses +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +MANIFEST_PATH = ( + Path(__file__).resolve().parent.parent + / "components" + / "runners" + / "ambient-runner" + / "sdk-options-manifest.json" +) + + +def get_current_fields() -> dict[str, dict[str, object]]: + """Introspect ClaudeAgentOptions and return a dict of field name -> metadata.""" + # Pydantic v2 + if hasattr(ClaudeAgentOptions, "model_fields"): + fields_map = ClaudeAgentOptions.model_fields + result = {} + for name, field_info in fields_map.items(): + annotation = field_info.annotation + type_str = str(annotation) if annotation else "Any" + required = field_info.is_required() + result[name] = {"type": type_str, "required": required} + return result + + # Pydantic v1 + if hasattr(ClaudeAgentOptions, "__fields__"): + fields_map = ClaudeAgentOptions.__fields__ + result = {} + for name, field_info in fields_map.items(): + type_str = ( + str(field_info.outer_type_) + if hasattr(field_info, "outer_type_") + else str(field_info.type_) + ) + required = field_info.required + result[name] = {"type": type_str, "required": required} + return result + + # dataclass + if dataclasses.is_dataclass(ClaudeAgentOptions): + result = {} + for f in dataclasses.fields(ClaudeAgentOptions): + has_default = f.default is not dataclasses.MISSING + has_factory = f.default_factory is not dataclasses.MISSING + required = not has_default and not has_factory + type_str = str(f.type) if f.type else "Any" + result[f.name] = {"type": type_str, "required": required} + return result + + print( + "ERROR: ClaudeAgentOptions is not a Pydantic model or dataclass — cannot introspect fields", + file=sys.stderr, + ) + sys.exit(2) + + +def load_manifest() -> dict: + """Load the existing manifest from disk.""" + if not MANIFEST_PATH.exists(): + print(f"ERROR: Manifest not found at {MANIFEST_PATH}", file=sys.stderr) + sys.exit(2) + with open(MANIFEST_PATH, encoding="utf-8") as fh: + return json.load(fh) + + +def write_manifest( + current_fields: dict[str, dict[str, object]], sdk_version: str +) -> None: + """Write an updated manifest to disk.""" + manifest = { + "description": "Canonical list of Claude Agent SDK ClaudeAgentOptions fields", + "generatedFrom": "claude-agent-sdk (PyPI)", + "generatedAt": datetime.now(timezone.utc).isoformat(), + "sdkVersion": sdk_version, + "options": current_fields, + } + with open(MANIFEST_PATH, "w", encoding="utf-8") as fh: + json.dump(manifest, fh, indent=2) + fh.write("\n") + print(f"Updated manifest written to {MANIFEST_PATH}") + + +def main(sdk_version: str) -> int: + current_fields = get_current_fields() + manifest = load_manifest() + manifest_options = manifest.get("options", {}) + + current_names = set(current_fields.keys()) + manifest_names = set(manifest_options.keys()) + + added = sorted(current_names - manifest_names) + removed = sorted(manifest_names - current_names) + + # Check type changes for fields present in both + changed: list[tuple[str, str, str]] = [] + for name in sorted(current_names & manifest_names): + old_type = manifest_options[name].get("type", "") + new_type = current_fields[name].get("type", "") + if old_type != new_type: + changed.append((name, old_type, new_type)) + + if not added and not removed and not changed: + print( + f"No drift detected (SDK {sdk_version}, manifest {manifest.get('sdkVersion', 'unknown')})" + ) + return 0 + + # Drift found + print("SDK options drift detected!") + print( + f" SDK version: {sdk_version} (manifest: {manifest.get('sdkVersion', 'unknown')})" + ) + if added: + print(f"\n Added fields ({len(added)}):") + for name in added: + print(f" + {name}: {current_fields[name]['type']}") + if removed: + print(f"\n Removed fields ({len(removed)}):") + for name in removed: + print(f" - {name}: {manifest_options[name]['type']}") + if changed: + print(f"\n Changed types ({len(changed)}):") + for name, old_type, new_type in changed: + print(f" ~ {name}: {old_type} -> {new_type}") + + write_manifest(current_fields, sdk_version) + return 1 + + +if __name__ == "__main__": + try: + import importlib.metadata + + from claude_agent_sdk import ClaudeAgentOptions + + sdk_version = importlib.metadata.version("claude-agent-sdk") + except ImportError as exc: + print(f"ERROR: Cannot import claude_agent_sdk: {exc}", file=sys.stderr) + print("Install it: pip install claude-agent-sdk", file=sys.stderr) + sys.exit(2) + except Exception as exc: + print(f"ERROR: {exc}", file=sys.stderr) + sys.exit(2) + + sys.exit(main(sdk_version)) diff --git a/specs/010-advanced-sdk-options/plan.md b/specs/010-advanced-sdk-options/plan.md new file mode 100644 index 000000000..87f1dc5bf --- /dev/null +++ b/specs/010-advanced-sdk-options/plan.md @@ -0,0 +1,88 @@ +# Implementation Plan: Advanced SDK Options + +**Branch**: `010-advanced-sdk-options` | **Date**: 2026-04-15 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/010-advanced-sdk-options/spec.md` + +## Summary + +Expose Claude Agent SDK options (temperature, tokens, tools, system prompt, etc.) in the session creation UI. Options flow from a React form through Go backend validation to a Python runner, where they merge into `ClaudeAgentOptions`. Defense-in-depth via backend allowlist + runner denylist. A weekly GHA workflow detects SDK drift. + +## Technical Context + +**Language/Version**: Go 1.22+ (backend), TypeScript/Next.js 14 (frontend), Python 3.12 (runner) +**Primary Dependencies**: Gin (backend HTTP), React + Shadcn/ui (frontend), claude-agent-sdk (runner) +**Storage**: Kubernetes CRDs — options travel as JSON string in existing `environmentVariables` map (no CRD changes) +**Testing**: go test (backend), vitest (frontend), pytest (runner) +**Target Platform**: Kubernetes cluster (OpenShift/kind) +**Project Type**: Web application (Go API + React frontend + Python runner) + +## Constitution Check + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. K8s-Native | PASS | Uses existing CR env vars, no new CRDs | +| II. Security | PASS | Allowlist + denylist + append-only system prompt | +| III. Type Safety | PASS | Backend type validation per key, no `any` in frontend | +| IV. TDD | ENFORCED | Tests required for each component | +| V. Modularity | PASS | Single-file component, handler functions, bridge method | +| X. Commit Discipline | PASS | Feature split into backend/frontend/runner commits | + +## Project Structure + +### Documentation (this feature) + +```text +specs/010-advanced-sdk-options/ +├── spec.md # Feature specification +├── plan.md # This file +└── tasks.md # Task breakdown +``` + +### Source Code (files to create or modify) + +```text +components/backend/ +├── handlers/sessions.go # MODIFY: add filterSdkOptions, validateSdkOptionValue, allowlist +└── types/session.go # MODIFY: add SdkOptions field to request types + +components/frontend/src/ +├── components/ +│ └── advanced-sdk-options.tsx # CREATE: collapsible SDK options form +├── app/projects/[name]/ +│ ├── new/page.tsx # MODIFY: wire sdkOptions into create call +│ └── sessions/[sessionName]/components/ +│ └── new-session-view.tsx # MODIFY: add AdvancedSdkOptions + feature flag gate +└── types/api/sessions.ts # MODIFY: add SdkOptions type + +components/runners/ambient-runner/ +├── ambient_runner/bridges/claude/bridge.py # MODIFY: parse SDK_OPTIONS, denylist, merge +├── sdk-options-manifest.json # CREATE: canonical SDK field list +└── tests/test_sdk_options.py # CREATE: SDK_OPTIONS parsing tests + +components/manifests/base/core/flags.json # MODIFY: add advanced-sdk-options flag + +.github/workflows/ +└── claude-sdk-options-drift.yml # CREATE: weekly drift detection + +components/backend/handlers/ +└── sessions_sdk_options_test.go # CREATE: backend filterSdkOptions tests + +components/frontend/src/components/__tests__/ +└── advanced-sdk-options.test.tsx # CREATE: frontend component tests +``` + +## Design Decisions + +1. **Reuse existing form components** — `claude-agent-options/` already exists on main with schema, options-form, and 11 field editors covering all SDK fields. `advanced-sdk-options.tsx` is a thin collapsible wrapper around `AgentOptionsFields`. + +2. **Backend allowlist as map literal** — `allowedSdkOptionKeys map[string]bool` at package level. Keys derived from `claudeAgentOptionsSchema` minus platform-internal keys. Backend does key filtering only — JSON marshal handles type serialization. + +3. **Runner denylist as frozenset** — `_SDK_OPTIONS_DENYLIST` at module level. Blocks platform-internal keys (`cwd`, `api_key`, `mcp_servers`, `setting_sources`, `stderr`, `resume`, `continue_conversation`, `add_dirs`) even if backend is bypassed. + +4. **SDK_OPTIONS as JSON string in env var** — Avoids CRD changes. The `environmentVariables` map already exists on the CR spec. + +5. **System prompt append-only** — User text appended under `## Custom Instructions` heading. Prevents users from stripping platform security instructions. + +6. **Feature flag UI-only** — Backend always accepts `sdkOptions` for API callers. Flag gates the form in the frontend only. + +7. **Rename agentOptions → sdkOptions** — Frontend types already have `agentOptions?: Record` on the request type. Rename to `sdkOptions` for clarity (matches SDK wire format). diff --git a/specs/010-advanced-sdk-options/spec.md b/specs/010-advanced-sdk-options/spec.md new file mode 100644 index 000000000..605dda347 --- /dev/null +++ b/specs/010-advanced-sdk-options/spec.md @@ -0,0 +1,123 @@ +# Feature Specification: Advanced SDK Options + +**Feature Branch**: `feat/advanced-sdk-options-v2` +**Created**: 2026-04-15 +**Status**: Draft +**Input**: Re-implementation of PR #1146 — expose Claude Agent SDK options in session creation UI + +## Overview + +Allow platform users to configure Claude Agent SDK parameters when creating sessions. Options flow from a frontend form through backend validation to the runner, where they merge into `ClaudeAgentOptions`. Gated behind workspace feature flag `advanced-sdk-options` (disabled by default). No CRD changes — options travel as a JSON string in the existing `environmentVariables` map. + +### Data Flow + +``` +Frontend form → sdkOptions on POST request + → Backend: allowlist filter + type validation → JSON string + → CR environmentVariables["SDK_OPTIONS"] + → Runner: parse, denylist filter, merge into adapter options + → ClaudeAgentAdapter(options) +``` + +### Security + +- **Backend allowlist**: Only permitted keys with valid types pass through. Everything else silently dropped. +- **Runner denylist**: Blocks platform-internal keys (`cwd`, `api_key`, etc.) even if backend is bypassed. +- **System prompt**: Append-only. User text goes under `## Custom Instructions`, never replaces platform prompt. +- **Feature flag**: UI-only gate. The API always accepts `sdkOptions` for programmatic callers. + +## User Scenarios & Testing + +### User Story 1 - Configure SDK Options on Session Creation (Priority: P1) + +A user creating a session wants to tune Claude — lower temperature, increase token budget, set a custom system prompt, or restrict tools. + +**Why this priority**: The entire feature. Everything else is a subset of this. + +**Independent Test**: Create a session with `sdkOptions` via API, verify the runner receives and applies them. + +**Acceptance Scenarios**: + +1. **Given** `advanced-sdk-options` is enabled for a workspace, **When** a user opens the new session page, **Then** a collapsible "Advanced SDK Options" section appears (collapsed by default). + +2. **Given** the user sets temperature to 0.3 and max_turns to 5 and submits, **When** the backend processes the request, **Then** the CR has `SDK_OPTIONS={"temperature":0.3,"max_turns":5}` in its env vars. + +3. **Given** the runner pod starts with `SDK_OPTIONS`, **When** the adapter initializes, **Then** the parsed options are merged into `ClaudeAgentOptions` (minus denylisted keys). + +4. **Given** `advanced-sdk-options` is disabled, **When** a user opens the new session page, **Then** the advanced options section is not visible. + +5. **Given** the user provides a system_prompt, **When** the runner merges options, **Then** the platform prompt is preserved and the user text is appended under `## Custom Instructions`. + +6. **Given** `SDK_OPTIONS` contains invalid JSON, **When** the runner parses it, **Then** it logs a warning and proceeds with platform defaults. + +--- + +### User Story 2 - SDK Options Drift Detection (Priority: P2) + +The Claude Agent SDK evolves. The platform must detect when `ClaudeAgentOptions` fields change and alert maintainers so the allowlist/UI stay current. + +**Why this priority**: Without this, the platform silently drifts from the SDK. Users can't access new options and removed options cause silent failures. + +**Independent Test**: Run the drift workflow via `workflow_dispatch`, verify it detects a simulated field change. + +**Acceptance Scenarios**: + +1. **Given** `claude-agent-sdk` on PyPI has added a new field, **When** the weekly workflow runs, **Then** it updates `sdk-options-manifest.json` and opens a PR labeled `amber:auto-fix`. + +2. **Given** no drift exists, **When** the workflow runs, **Then** no PR is created and the job succeeds cleanly. + +3. **Given** the workflow encounters a PyPI install failure, **When** it runs, **Then** it fails loudly (non-zero exit) rather than silently skipping. + +--- + +### Edge Cases + +- `SDK_OPTIONS` is a JSON array instead of object → runner logs warning, uses platform defaults. +- User sends `sdkOptions` with unknown keys → backend silently drops them, no error. +- User sends `temperature: "hot"` → backend returns 400 with type validation error. +- `SDK_OPTIONS` contains `api_key` → runner denylist blocks it. +- User sends empty `sdkOptions: {}` → no `SDK_OPTIONS` env var set (no-op). + +## Requirements + +### Functional Requirements + +**Backend:** + +- **FR-001**: `CreateAgenticSessionRequest` accepts optional `sdkOptions map[string]interface{}`. +- **FR-002**: Backend filters `sdkOptions` through an allowlist and validates types per key. Returns 400 on type mismatch. Silently drops unknown keys. +- **FR-003**: Filtered options are JSON-serialized into `environmentVariables["SDK_OPTIONS"]` on the CR. + +**Runner:** + +- **FR-004**: Runner parses `SDK_OPTIONS` env var as JSON on adapter init. Malformed input → warn + use defaults. +- **FR-005**: Runner applies a denylist for platform-internal keys (`cwd`, `api_key`, `mcp_servers`, `setting_sources`, `stderr`, `resume`, `continue_conversation`, `add_dirs`, `cli_path`, `env`). Logs a warning per blocked key. +- **FR-006**: `system_prompt` is appended under `## Custom Instructions`, not replaced. + +**Frontend:** + +- **FR-007**: `AdvancedSdkOptions` component renders behind `advanced-sdk-options` workspace flag. Collapsed by default. +- **FR-008**: Field names use snake_case matching the Python SDK wire format. +- **FR-009**: `sdkOptions` is only included in the create request when at least one value is set. + +**Drift Detection:** + +- **FR-010**: Weekly GHA workflow introspects `ClaudeAgentOptions` from `claude-agent-sdk` PyPI package and compares against `sdk-options-manifest.json`. +- **FR-011**: On drift: updates manifest, opens PR with `amber:auto-fix` label. On no drift: clean exit. On error: hard fail. + +**Feature Flag:** + +- **FR-012**: `advanced-sdk-options` defined in `flags.json` with `scope:workspace` tag. Gates UI only. + +### Key Entities + +- **SdkOptions**: Map of SDK parameter names (snake_case) to values. Travels as JSON string through CR env vars. +- **SDK Options Manifest**: JSON file recording `ClaudeAgentOptions` fields/types from PyPI. Source of truth for drift detection. + +## Success Criteria + +- **SC-001**: Sessions created with custom SDK options produce observably different agent behavior. +- **SC-002**: Backend rejects invalid types with 400. +- **SC-003**: Runner never passes denylisted keys to the SDK. +- **SC-004**: System prompt append-only behavior verified by test. +- **SC-005**: Drift workflow detects field changes on manual trigger. diff --git a/specs/010-advanced-sdk-options/tasks.md b/specs/010-advanced-sdk-options/tasks.md new file mode 100644 index 000000000..2adebc1de --- /dev/null +++ b/specs/010-advanced-sdk-options/tasks.md @@ -0,0 +1,96 @@ +# Tasks: Advanced SDK Options + +**Input**: Design documents from `/specs/010-advanced-sdk-options/` +**Prerequisites**: plan.md (required), spec.md (required) + +**Execution skill**: `superpowers:subagent-driven-development` (one subagent per phase, review between phases) + +## Phase 1: Setup + +- [ ] T001 Add `advanced-sdk-options` feature flag to `components/manifests/base/core/flags.json` with `scope:workspace` tag + +### Commit: `feat(flags): add advanced-sdk-options workspace feature flag` + +--- + +## Phase 2: Backend — SDK Options Filtering (TDD) + +**Goal**: Backend accepts `sdkOptions` on session create, filters through allowlist, validates types, serializes to `SDK_OPTIONS` env var on the CR. + +- [ ] T010 [US1] Add `SdkOptions map[string]interface{}` field with `json:"sdkOptions,omitempty"` to `CreateAgenticSessionRequest` in `components/backend/types/session.go` +- [ ] T011 [US1] Create `components/backend/handlers/sessions_sdk_options_test.go` (TDD — tests first, then implement). Tests: valid keys pass through, unknown keys silently dropped, empty map returns nil, string/numeric/bool/slice type checks, invalid type returns error +- [ ] T012 [US1] Implement `allowedSdkOptionKeys` map and `filterSdkOptions` in `components/backend/handlers/sessions.go`. Allowlist keys: all fields from `claudeAgentOptionsSchema` minus denylisted keys (`cwd`, `resume`, `mcp_servers`, `setting_sources`, `continue_conversation`, `add_dirs`, `cli_path`, `settings`, `permission_prompt_tool_name`, `fork_session`). Include `validateSdkOptionValue` for basic type checks on primitives (string, float, int, bool, slice). Complex objects (hooks, agents, sandbox, thinking, mcp_servers) pass through as-is — JSON marshal handles them. +- [ ] T013 [US1] Wire into `CreateAgenticSession` handler: if `req.SdkOptions` is non-empty, call `filterSdkOptions`, return 400 on error, JSON-serialize into `envVars["SDK_OPTIONS"]` +- [ ] T014 [US1] Run backend tests: `cd components/backend && go test -tags test -run TestSdkOptions ./handlers/` + +### Commit: `feat(backend): add sdkOptions allowlist and type validation` + +--- + +## Phase 3: Runner — SDK_OPTIONS Parsing (TDD) + +**Goal**: Runner parses `SDK_OPTIONS` env var, applies denylist, merges system_prompt append-only, passes remaining options to adapter. + +- [ ] T020 [US1] Create `components/runners/ambient-runner/tests/test_sdk_options.py` (TDD). Tests: valid JSON parsed, malformed JSON returns empty dict, JSON array returns empty dict, denylisted keys blocked with warning, non-denylisted keys pass, system_prompt appended under `## Custom Instructions` heading +- [ ] T021 [US1] Implement `_SDK_OPTIONS_DENYLIST` frozenset and SDK_OPTIONS parsing in `components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py`. In `_ensure_adapter`: parse env var, apply denylist with per-key warning logs, handle system_prompt append, merge remaining keys into options dict +- [ ] T022 [US1] Run runner tests: `cd components/runners/ambient-runner && python -m pytest tests/test_sdk_options.py -v` + +### Commit: `feat(runner): parse SDK_OPTIONS env var with denylist and system prompt merge` + +--- + +## Phase 4: Frontend — Wire Form into Session Creation (TDD) + +**Goal**: Wrap existing `claude-agent-options/` form in a collapsible container, gate behind feature flag, wire into session create flow. + +**Existing on main**: `components/frontend/src/components/claude-agent-options/` has `AgentOptionsFields`, `claudeAgentOptionsSchema`, `claudeAgentOptionsDefaults`, and 11 field editors. Reuse these. + +- [ ] T030 [US1] Rename `agentOptions` to `sdkOptions` in `components/frontend/src/types/api/sessions.ts` and `components/frontend/src/types/agentic-session.ts` +- [ ] T031 [US1] Create `components/frontend/src/components/__tests__/advanced-sdk-options.test.tsx` (TDD). Tests: not rendered when flag is disabled, renders collapsed by default when flag enabled, expands on click, form fields visible when expanded +- [ ] T032 [US1] Create `components/frontend/src/components/advanced-sdk-options.tsx` — collapsible wrapper using Shadcn `Collapsible`. Imports `AgentOptionsFields` from `claude-agent-options`. Props: `projectName`, `form: UseFormReturn`, `disabled?`. Uses `useWorkspaceFlag(projectName, "advanced-sdk-options")` to gate visibility +- [ ] T033 [US1] Wire into `components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/new-session-view.tsx`: add `useForm` with defaults, render ``, pass non-empty form values as `sdkOptions` in `onCreateSession` callback +- [ ] T034 [US1] Wire into `components/frontend/src/app/projects/[name]/new/page.tsx`: accept `sdkOptions` in config, spread into create mutation payload +- [ ] T035 [US1] Run frontend tests and build: `cd components/frontend && npx vitest run && npm run build` + +### Commit: `feat(frontend): add collapsible AdvancedSdkOptions gated by workspace flag` + +--- + +## Phase 5: Drift Detection (US2) + +**Goal**: Weekly GHA workflow introspects `ClaudeAgentOptions` from PyPI, compares against manifest, opens PR on drift. + +- [ ] T040 [US2] Generate `components/runners/ambient-runner/sdk-options-manifest.json` by introspecting the current `claude-agent-sdk` package: install via `uv pip install claude-agent-sdk`, extract fields from `ClaudeAgentOptions.model_fields` (Pydantic), write `{"generatedFrom": "claude-agent-sdk", "generatedAt": "", "sdkVersion": "", "options": {"field_name": {"type": "", "required": }}}` +- [ ] T041 [US2] Create `scripts/sdk-options-drift-check.py`: import `ClaudeAgentOptions`, introspect via `model_fields`, compare against manifest, exit 0 (no drift), exit 1 (drift found — write updated manifest), exit 2 (error). Must handle: `ImportError` (hard fail), Pydantic v1 vs v2 (check for `model_fields` vs `__fields__`) +- [ ] T042 [US2] Add drift check step to `.github/workflows/sdk-version-bump.yml`: after "Apply updates" step, `pip install claude-agent-sdk`, run `scripts/sdk-options-drift-check.py`, include updated manifest in the commit if drift found. No standalone workflow — drift detection runs as part of the daily SDK version bump. +- [ ] T043 [US2] Test drift detection end-to-end: run `python scripts/sdk-options-drift-check.py` locally, verify clean exit with current manifest + +### Commit: `refactor(ci): consolidate drift detection into sdk-version-bump workflow` + +--- + +## Phase 6: Verify + +- [ ] T050 Run all component test suites: backend (`make test`), frontend (`npx vitest run --coverage`), runner (`python -m pytest tests/ -v`) +- [ ] T051 Run `npm run build` in frontend (must pass with 0 errors, 0 warnings) +- [ ] T052 Run `make lint` (pre-commit hooks on all changed files) +- [ ] T053 Grep changed `.tsx`/`.ts` files for `: any` or `as any` — must be zero +- [ ] T054 Cross-reference spec acceptance scenarios SC-001 through SC-005 against test coverage + +### Commit (if fixes needed): `chore: lint and polish for advanced SDK options` + +--- + +## Dependencies + +- **Phase 1** → Phases 2, 3, 4 (flag must exist before frontend gate works) +- **Phase 2** → Phase 4 (backend must accept `sdkOptions` before frontend sends it) +- **Phase 3** → independent (runner reads env var, no compile-time dependency on backend) +- **Phase 4** → depends on Phase 2 (API contract) +- **Phase 5** → independent (drift workflow has no code dependency on other phases) +- **Phase 6** → all phases complete + +### Parallel opportunities + +- **Phases 2 + 3 + 5** can run in parallel (backend, runner, drift are independent) +- Within Phase 4: T030 (types) must precede T031-T035