diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 8095dceeea..0fd2193cd6 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -155,7 +155,7 @@ reviews: 3. Expensive work inside loops (API calls, JSON parsing, regex compilation). 4. Unbounded growth: caches, watchers, buffers without eviction/limits. 5. Missing pagination/limits on List operations or API endpoints. - 6. Frontend: unnecessary rerenders, missing memoization, unvirtualized large lists, missing dependency arrays, unbounded localStorage, sessionStorage or Cookies. Blocking HTTP requests. + 6. Frontend: unnecessary rerenders, missing memoization, unvirtualized large lists, missing dependency arrays, unbounded localStorage, sessionStorage or Cookies. Blocking HTTP requests. Per issue: file, lines, risk, fix category. If clean, mark PASSED. diff --git a/.github/workflows/daily-sdk-update.yml b/.github/workflows/daily-sdk-update.yml index 7d1e6d2937..54592302e2 100644 --- a/.github/workflows/daily-sdk-update.yml +++ b/.github/workflows/daily-sdk-update.yml @@ -18,6 +18,7 @@ concurrency: jobs: update-sdk: name: Update claude-agent-sdk to latest + if: github.event_name != 'pull_request' runs-on: ubuntu-latest timeout-minutes: 15 @@ -158,33 +159,28 @@ jobs: git push -u origin "$BRANCH" - PR_BODY=$(cat <=${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) - -> **Note:** PRs created by \`GITHUB_TOKEN\` do not automatically trigger \`pull_request\` workflows. -> CI must be triggered manually (push an empty commit or re-run workflows) or the repo can be -> configured with a PAT via \`secrets.BOT_TOKEN\` to enable automatic CI triggering. - ---- -*Auto-generated by daily-sdk-update workflow* -PREOF - ) + 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 "$PR_BODY" \ + --body-file /tmp/pr-body.md \ --base main \ --head "$BRANCH" diff --git a/.gitignore b/.gitignore index d3abf73268..49fa112e01 100644 --- a/.gitignore +++ b/.gitignore @@ -88,6 +88,9 @@ dmypy.json # Claude Code .claude/settings.local.json +.claude/worktrees/ + +# Git worktrees .worktrees/ # mkdocs diff --git a/components/backend/handlers/permissions.go b/components/backend/handlers/permissions.go index 0f01005ef6..a3a0622759 100755 --- a/components/backend/handlers/permissions.go +++ b/components/backend/handlers/permissions.go @@ -389,6 +389,21 @@ func CreateProjectKey(c *gin.Context) { return } + // Validate and apply token expiration (required, max 1 year). + // Kubernetes TokenRequest does not support non-expiring tokens — the API + // server silently caps ExpirationSeconds and the token will expire even if + // you omit the field (default ~1h). We enforce an explicit maximum of 1 + // year so users get predictable behaviour instead of a silent K8s default. + const maxExpirationSeconds int64 = 31536000 // 1 year + if req.ExpirationSeconds == nil || *req.ExpirationSeconds <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "expirationSeconds is required and must be greater than 0"}) + return + } + if *req.ExpirationSeconds > maxExpirationSeconds { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("expirationSeconds must not exceed %d (1 year)", maxExpirationSeconds)}) + return + } + // Create a dedicated ServiceAccount per key uid := uuid.New().String()[:8] saName := fmt.Sprintf("ambient-key-%s-%s", sanitizeName(req.Name), uid) @@ -434,10 +449,9 @@ func CreateProjectKey(c *gin.Context) { return } - // Issue a one-time JWT token for this ServiceAccount (no audience; used as API key) - tokenSpec := authnv1.TokenRequestSpec{} - if req.ExpirationSeconds != nil && *req.ExpirationSeconds > 0 { - tokenSpec.ExpirationSeconds = req.ExpirationSeconds + // Generate token with validated expiration + tokenSpec := authnv1.TokenRequestSpec{ + ExpirationSeconds: req.ExpirationSeconds, } tr := &authnv1.TokenRequest{Spec: tokenSpec} tok, err := k8sClient.CoreV1().ServiceAccounts(projectName).CreateToken(context.TODO(), saName, tr, v1.CreateOptions{}) diff --git a/components/backend/handlers/permissions_test.go b/components/backend/handlers/permissions_test.go index 552b68e436..4bdbb68354 100644 --- a/components/backend/handlers/permissions_test.go +++ b/components/backend/handlers/permissions_test.go @@ -848,6 +848,131 @@ var _ = Describe("Permissions Handler", Ordered, Label(test_constants.LabelUnit, }) }) + Context("CreateProjectKey Expiration Validation", func() { + It("Should reject missing expirationSeconds", func() { + requestBody := map[string]interface{}{ + "name": "test-key", + "role": "edit", + } + + ginContext := httpUtils.CreateTestGinContext("POST", "/api/projects/test-project/keys", requestBody) + ginContext.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + httpUtils.SetAuthHeader("test-token") + ginContext.Set("userID", "test-user") + + CreateProjectKey(ginContext) + + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + httpUtils.AssertErrorMessage("expirationSeconds is required") + }) + + It("Should reject zero expirationSeconds", func() { + requestBody := map[string]interface{}{ + "name": "test-key", + "role": "edit", + "expirationSeconds": 0, + } + + ginContext := httpUtils.CreateTestGinContext("POST", "/api/projects/test-project/keys", requestBody) + ginContext.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + httpUtils.SetAuthHeader("test-token") + ginContext.Set("userID", "test-user") + + CreateProjectKey(ginContext) + + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + httpUtils.AssertErrorMessage("expirationSeconds is required") + }) + + It("Should reject negative expirationSeconds", func() { + requestBody := map[string]interface{}{ + "name": "test-key", + "role": "edit", + "expirationSeconds": -1, + } + + ginContext := httpUtils.CreateTestGinContext("POST", "/api/projects/test-project/keys", requestBody) + ginContext.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + httpUtils.SetAuthHeader("test-token") + ginContext.Set("userID", "test-user") + + CreateProjectKey(ginContext) + + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + httpUtils.AssertErrorMessage("expirationSeconds is required") + }) + + It("Should reject expirationSeconds exceeding 1 year", func() { + requestBody := map[string]interface{}{ + "name": "test-key", + "role": "edit", + "expirationSeconds": 31536001, + } + + ginContext := httpUtils.CreateTestGinContext("POST", "/api/projects/test-project/keys", requestBody) + ginContext.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + httpUtils.SetAuthHeader("test-token") + ginContext.Set("userID", "test-user") + + CreateProjectKey(ginContext) + + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + httpUtils.AssertErrorMessage("must not exceed 31536000") + }) + + It("Should accept expirationSeconds at exactly 1 year", func() { + requestBody := map[string]interface{}{ + "name": "test-key", + "role": "edit", + "expirationSeconds": 31536000, + } + + ginContext := httpUtils.CreateTestGinContext("POST", "/api/projects/test-project/keys", requestBody) + ginContext.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + httpUtils.SetAuthHeader("test-token") + ginContext.Set("userID", "test-user") + + CreateProjectKey(ginContext) + + // Should pass validation and proceed to SA creation (not 400) + status := httpUtils.GetResponseRecorder().Code + Expect(status).NotTo(Equal(http.StatusBadRequest), + "Valid 1-year expiration should not be rejected") + }) + + It("Should accept valid 90-day expirationSeconds", func() { + requestBody := map[string]interface{}{ + "name": "test-key", + "role": "edit", + "expirationSeconds": 7776000, + } + + ginContext := httpUtils.CreateTestGinContext("POST", "/api/projects/test-project/keys", requestBody) + ginContext.Params = gin.Params{ + {Key: "projectName", Value: "test-project"}, + } + httpUtils.SetAuthHeader("test-token") + ginContext.Set("userID", "test-user") + + CreateProjectKey(ginContext) + + // Should pass validation and proceed to SA creation (not 400) + status := httpUtils.GetResponseRecorder().Code + Expect(status).NotTo(Equal(http.StatusBadRequest), + "Valid 90-day expiration should not be rejected") + }) + }) + Context("Resource Label Verification", func() { It("Should create resources with proper ambient-code labels", func() { requestBody := map[string]interface{}{ diff --git a/components/frontend/src/app/projects/[name]/keys/page.tsx b/components/frontend/src/app/projects/[name]/keys/page.tsx index 13294c9b0b..127d902057 100644 --- a/components/frontend/src/app/projects/[name]/keys/page.tsx +++ b/components/frontend/src/app/projects/[name]/keys/page.tsx @@ -21,17 +21,7 @@ import { useKeys, useCreateKey, useDeleteKey } from '@/services/queries'; import { toast } from 'sonner'; import type { CreateKeyRequest } from '@/services/api/keys'; import { ROLE_DEFINITIONS } from '@/lib/role-colors'; - -const EXPIRATION_OPTIONS = [ - { value: '86400', label: '1 day' }, - { value: '604800', label: '7 days' }, - { value: '2592000', label: '30 days' }, - { value: '7776000', label: '90 days' }, - { value: '31536000', label: '1 year' }, - { value: 'none', label: 'No expiration' }, -] as const; - -const DEFAULT_EXPIRATION = '7776000'; // 90 days +import { EXPIRATION_OPTIONS, DEFAULT_EXPIRATION } from '@/lib/constants'; export default function ProjectKeysPage() { const params = useParams(); @@ -60,7 +50,7 @@ export default function ProjectKeysPage() { name: newKeyName.trim(), description: newKeyDesc.trim() || undefined, role: newKeyRole, - expirationSeconds: newKeyExpiration !== 'none' ? Number(newKeyExpiration) : undefined, + expirationSeconds: Number(newKeyExpiration), }; createKeyMutation.mutate( @@ -303,7 +293,7 @@ export default function ProjectKeysPage() {
- @@ -315,9 +305,6 @@ export default function ProjectKeysPage() { ))} -

- How long the token remains valid. Choose "No expiration" for long-lived service keys. -

diff --git a/components/frontend/src/lib/constants.ts b/components/frontend/src/lib/constants.ts index 2536a176a0..d418801317 100644 --- a/components/frontend/src/lib/constants.ts +++ b/components/frontend/src/lib/constants.ts @@ -1,2 +1,15 @@ export const INACTIVITY_TIMEOUT_TOOLTIP = "The session is stopped when no activity (user messages) is detected for this duration. The countdown starts from the last activity time, or from session start if there is no interaction. When set to 0, auto-stop is disabled entirely."; + +// Kubernetes TokenRequest does not support non-expiring tokens — the API server +// silently caps ExpirationSeconds. Max is 1 year; "No expiration" is not offered +// because K8s will expire the token regardless. +export const EXPIRATION_OPTIONS = [ + { value: '86400', label: '1 day' }, + { value: '604800', label: '7 days' }, + { value: '2592000', label: '30 days' }, + { value: '7776000', label: '90 days' }, + { value: '31536000', label: '1 year' }, +] as const; + +export const DEFAULT_EXPIRATION = '7776000'; // 90 days