diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 823d192ea..e9a1ba5e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,11 +56,13 @@ jobs: VERSION="ci-$COMMIT" mkdir -p dist - LDFLAGS="-s -w -X go.kenn.io/msgvault/cmd/msgvault/cmd.Version=${VERSION} -X go.kenn.io/msgvault/cmd/msgvault/cmd.Commit=${COMMIT} -X go.kenn.io/msgvault/cmd/msgvault/cmd.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ) -extldflags '-lstdc++ -lm'" + CGO_ENABLED=0 go build -trimpath -buildvcs=false -o dist/msgvault-codex-bridge ./cmd/msgvault-codex-bridge + BRIDGE_SHA256=$(sha256sum dist/msgvault-codex-bridge | cut -d' ' -f1) + LDFLAGS="-s -w -X go.kenn.io/msgvault/cmd/msgvault/cmd.Version=${VERSION} -X go.kenn.io/msgvault/cmd/msgvault/cmd.Commit=${COMMIT} -X go.kenn.io/msgvault/cmd/msgvault/cmd.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ) -X go.kenn.io/msgvault/internal/peoplesweep.codexBridgeSHA256=${BRIDGE_SHA256} -extldflags '-lstdc++ -lm'" go build -tags "fts5 sqlite_vec" -trimpath -buildvcs=false -ldflags="$LDFLAGS" -o dist/msgvault ./cmd/msgvault echo "--- Binary info ---" - file dist/msgvault + file dist/msgvault dist/msgvault-codex-bridge ldd dist/msgvault || true echo "--- Runtime requirements ---" diff --git a/.gitignore b/.gitignore index bd02c851e..056423e87 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Go binaries /msgvault +/msgvault-codex-bridge /msgvault.exe /mimeshootout diff --git a/Dockerfile b/Dockerfile index 6d863bcb5..91e348258 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,13 +42,17 @@ ARG COMMIT=unknown ARG BUILD_DATE=unknown # Note: Module path must match go.mod (go.kenn.io/msgvault) -RUN CGO_ENABLED=1 go build \ +RUN CGO_ENABLED=0 go build -trimpath -buildvcs=false \ + -o /msgvault-codex-bridge ./cmd/msgvault-codex-bridge \ + && bridge_digest=$(sha256sum /msgvault-codex-bridge | cut -d' ' -f1) \ + && CGO_ENABLED=1 go build \ -tags "fts5 sqlite_vec" \ -trimpath \ -ldflags="-s -w \ -X go.kenn.io/msgvault/cmd/msgvault/cmd.Version=${VERSION} \ -X go.kenn.io/msgvault/cmd/msgvault/cmd.Commit=${COMMIT} \ - -X go.kenn.io/msgvault/cmd/msgvault/cmd.BuildDate=${BUILD_DATE}" \ + -X go.kenn.io/msgvault/cmd/msgvault/cmd.BuildDate=${BUILD_DATE} \ + -X go.kenn.io/msgvault/internal/peoplesweep.codexBridgeSHA256=${bridge_digest}" \ -o /msgvault \ ./cmd/msgvault @@ -61,6 +65,7 @@ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-ins tzdata \ wget \ libstdc++6 \ + bubblewrap \ && rm -rf /var/lib/apt/lists/* # Create non-root user @@ -69,6 +74,7 @@ RUN groupadd --gid 1000 msgvault \ # Copy binary from builder COPY --from=builder /msgvault /usr/local/bin/msgvault +COPY --from=builder --chown=msgvault:msgvault /msgvault-codex-bridge /usr/local/bin/msgvault-codex-bridge # Set up data directory with correct ownership ENV MSGVAULT_HOME=/data diff --git a/Makefile b/Makefile index 79620d0d5..b2b59122d 100644 --- a/Makefile +++ b/Makefile @@ -92,19 +92,32 @@ GOLANGCI_LINT_TMP ?= $(GOLANGCI_LINT_CACHE)/tmp # Build the binary (debug) build: web-embed +ifeq ($(shell go env GOOS),linux) + CGO_ENABLED=0 go build -trimpath -buildvcs=false -o msgvault-codex-bridge ./cmd/msgvault-codex-bridge + @bridge_digest=$$(sha256sum msgvault-codex-bridge | cut -d' ' -f1); \ + CGO_ENABLED=1 go build -tags "$(BUILD_TAGS)" -ldflags="$(LDFLAGS) -X go.kenn.io/msgvault/internal/peoplesweep.codexBridgeSHA256=$$bridge_digest" -o msgvault ./cmd/msgvault +else CGO_ENABLED=1 go build -tags "$(BUILD_TAGS)" -ldflags="$(LDFLAGS)" -o msgvault ./cmd/msgvault +endif @chmod +x msgvault # Build with optimizations (release) build-release: web-embed +ifeq ($(shell go env GOOS),linux) + CGO_ENABLED=0 go build -trimpath -buildvcs=false -o msgvault-codex-bridge ./cmd/msgvault-codex-bridge + @bridge_digest=$$(sha256sum msgvault-codex-bridge | cut -d' ' -f1); \ + CGO_ENABLED=1 go build -tags "$(BUILD_TAGS)" -ldflags="$(LDFLAGS_RELEASE) -X go.kenn.io/msgvault/internal/peoplesweep.codexBridgeSHA256=$$bridge_digest" -trimpath -o msgvault ./cmd/msgvault +else CGO_ENABLED=1 go build -tags "$(BUILD_TAGS)" -ldflags="$(LDFLAGS_RELEASE)" -trimpath -o msgvault ./cmd/msgvault +endif @chmod +x msgvault # Install to ~/.local/bin, $GOBIN, or $GOPATH/bin -install: web-embed - @if [ -d "$(HOME)/.local/bin" ]; then \ +install: build + @set -e; if [ -d "$(HOME)/.local/bin" ]; then \ echo "Installing to ~/.local/bin/msgvault"; \ - CGO_ENABLED=1 go build -tags "$(BUILD_TAGS)" -ldflags="$(LDFLAGS)" -o "$(HOME)/.local/bin/msgvault" ./cmd/msgvault; \ + install -m 755 msgvault "$(HOME)/.local/bin/msgvault"; \ + if [ "$$(go env GOOS)" = linux ]; then install -m 755 msgvault-codex-bridge "$(HOME)/.local/bin/msgvault-codex-bridge"; fi; \ else \ INSTALL_DIR="$${GOBIN:-$$(go env GOBIN)}"; \ if [ -z "$$INSTALL_DIR" ]; then \ @@ -113,12 +126,13 @@ install: web-embed fi; \ mkdir -p "$$INSTALL_DIR"; \ echo "Installing to $$INSTALL_DIR/msgvault"; \ - CGO_ENABLED=1 go build -tags "$(BUILD_TAGS)" -ldflags="$(LDFLAGS)" -o "$$INSTALL_DIR/msgvault" ./cmd/msgvault; \ + install -m 755 msgvault "$$INSTALL_DIR/msgvault"; \ + if [ "$$(go env GOOS)" = linux ]; then install -m 755 msgvault-codex-bridge "$$INSTALL_DIR/msgvault-codex-bridge"; fi; \ fi # Clean build artifacts clean: - rm -f msgvault msgvault.exe mimeshootout + rm -f msgvault msgvault.exe msgvault-codex-bridge mimeshootout rm -rf bin/ # Scale the SQLite suite when both CPU and memory budgets allow it. An explicit diff --git a/api/openapi.yaml b/api/openapi.yaml index ffad239b0..cb2756f81 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2364,6 +2364,25 @@ components: required: - stats type: object + CodexModel: + additionalProperties: true + properties: + default_reasoning_effort: + type: string + display_name: + type: string + id: + type: string + supported_efforts: + items: + type: string + type: array + required: + - id + - display_name + - default_reasoning_effort + - supported_efforts + type: object CommunicationService: additionalProperties: true properties: @@ -7907,6 +7926,261 @@ components: format: int64 type: integer type: object + PeopleCodexLoginRequest: + additionalProperties: false + properties: + name: + minLength: 1 + type: string + required: + - name + type: object + PeopleCodexLoginResponse: + additionalProperties: true + properties: + local_deadline: + format: date-time + type: string + session_id: + type: string + user_code: + type: string + verification_url: + type: string + required: + - session_id + - verification_url + - user_code + - local_deadline + type: object + PeopleCodexLoginStatusResponse: + additionalProperties: true + properties: + state: + type: string + required: + - state + type: object + PeopleCodexModelsResponse: + additionalProperties: true + properties: + models: + items: + $ref: "#/components/schemas/CodexModel" + type: array + required: + - models + type: object + PeopleCodexProfileRequest: + additionalProperties: false + properties: + allow_sensitive: + type: boolean + allowed_sources: + items: + type: string + minItems: 1 + type: array + model: + minLength: 1 + type: string + reasoning_effort: + minLength: 1 + type: string + retention_posture: + minLength: 1 + type: string + source_since: + type: string + source_until: + type: string + training_posture: + minLength: 1 + type: string + required: + - model + - reasoning_effort + - retention_posture + - training_posture + - allowed_sources + - source_since + - allow_sensitive + type: object + PeopleInferenceCheckResponse: + additionalProperties: true + properties: + fingerprint: + type: string + model: + type: string + ok: + type: boolean + usage: + $ref: "#/components/schemas/TokenUsage" + required: + - ok + - fingerprint + - model + - usage + type: object + PeopleInferenceConsentRequest: + additionalProperties: false + properties: + confirmed: + type: boolean + fingerprint: + type: string + required: + - fingerprint + - confirmed + type: object + PeopleInferenceKeyWriteRequest: + additionalProperties: false + properties: + value: + minLength: 1 + type: string + required: + - value + type: object + PeopleInferencePresetCreateRequest: + additionalProperties: false + properties: + allow_sensitive: + type: boolean + allowed_sources: + items: + type: string + minItems: 1 + type: array + model: + minLength: 1 + type: string + preset_id: + enum: + - openai + - openrouter + - venice + type: string + retention_posture: + minLength: 1 + type: string + source_since: + type: string + source_until: + type: string + training_posture: + minLength: 1 + type: string + required: + - preset_id + - model + - retention_posture + - training_posture + - allowed_sources + - source_since + - allow_sensitive + type: object + PeopleInferenceProfileSetting: + additionalProperties: true + properties: + allow_sensitive: + type: boolean + allowed_sources: + items: + type: string + type: array + checked: + type: boolean + consent_active: + type: boolean + credential_configured: + type: boolean + credential_env: + type: string + credential_revision: + type: string + credential_source: + type: string + endpoint: + type: string + fingerprint: + type: string + model: + type: string + name: + type: string + output_mode: + type: string + preset_id: + type: string + protocol: + type: string + retention_posture: + type: string + selected: + type: boolean + source_since: + type: string + source_until: + type: string + training_posture: + type: string + required: + - name + - selected + - protocol + - model + - credential_source + - credential_configured + - checked + - consent_active + - output_mode + - retention_posture + - training_posture + - allowed_sources + - source_since + - allow_sensitive + type: object + PeopleInferenceSelectionRequest: + additionalProperties: false + properties: + name: + minLength: 1 + type: string + required: + - name + type: object + PeopleInferenceSettingsResponse: + additionalProperties: true + properties: + configured_enabled: + type: boolean + configured_fingerprint: + type: string + configured_name: + type: string + pending_restart: + type: boolean + profiles: + items: + $ref: "#/components/schemas/PeopleInferenceProfileSetting" + type: array + running_enabled: + type: boolean + running_fingerprint: + type: string + running_name: + type: string + stored_credentials_supported: + type: boolean + required: + - stored_credentials_supported + - profiles + - configured_enabled + - running_enabled + - pending_restart + type: object PeopleResponse: additionalProperties: true properties: @@ -12843,6 +13117,19 @@ components: required: - refresh_token type: object + TokenUsage: + additionalProperties: true + properties: + input_tokens: + format: int64 + type: integer + output_tokens: + format: int64 + type: integer + required: + - input_tokens + - output_tokens + type: object TotalStatsResponse: additionalProperties: true properties: @@ -26271,6 +26558,1078 @@ paths: summary: Update browser-managed settings tags: - API + /api/v1/settings/people-inference: + get: + operationId: getSettingsPeopleInference + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceSettingsResponse" + description: OK + headers: + ETag: + description: Strong content hash for optimistic concurrency + schema: + type: string + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Get people inference provider status + tags: + - API + /api/v1/settings/people-inference/codex/login: + post: + operationId: startSettingsPeopleCodexLogin + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleCodexLoginRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleCodexLoginResponse" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "502": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "504": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Start a private Codex device login + tags: + - API + /api/v1/settings/people-inference/codex/login/{id}: + delete: + operationId: cancelSettingsPeopleCodexLogin + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleCodexLoginStatusResponse" + description: OK + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "502": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Cancel Codex device login + tags: + - API + get: + operationId: getSettingsPeopleCodexLogin + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleCodexLoginStatusResponse" + description: OK + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "502": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Get Codex device login status + tags: + - API + /api/v1/settings/people-inference/codex/login/{id}/models: + get: + operationId: getSettingsPeopleCodexModels + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleCodexModelsResponse" + description: OK + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "502": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: List models for completed Codex login + tags: + - API + /api/v1/settings/people-inference/codex/login/{id}/profile: + put: + operationId: putSettingsPeopleCodexProfile + parameters: + - in: path + name: id + required: true + schema: + type: string + - description: Strong config ETag returned by the latest settings read + in: header + name: If-Match + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleCodexProfileRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceSettingsResponse" + description: OK + headers: + ETag: + description: Strong content hash for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "412": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "422": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "502": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Create a Codex profile from completed device login + tags: + - API + /api/v1/settings/people-inference/disable: + post: + operationId: disableSettingsPeopleInference + parameters: + - description: Strong config ETag returned by the latest settings read + in: header + name: If-Match + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceSettingsResponse" + description: OK + headers: + ETag: + description: Strong content hash for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "412": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Disable people inference and revoke active consent + tags: + - API + /api/v1/settings/people-inference/providers/{name}: + delete: + operationId: deleteSettingsPeopleInferenceProvider + parameters: + - in: path + name: name + required: true + schema: + type: string + - description: Strong config ETag returned by the latest settings read + in: header + name: If-Match + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceSettingsResponse" + description: OK + headers: + ETag: + description: Strong content hash for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "412": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Remove a people inference provider profile + tags: + - API + put: + operationId: putSettingsPeopleInferencePreset + parameters: + - in: path + name: name + required: true + schema: + type: string + - description: Strong config ETag returned by the latest settings read + in: header + name: If-Match + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferencePresetCreateRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceSettingsResponse" + description: OK + headers: + ETag: + description: Strong content hash for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "412": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "422": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Create a vendor-bound people inference provider + tags: + - API + /api/v1/settings/people-inference/providers/{name}/check: + post: + operationId: checkSettingsPeopleInferenceProvider + parameters: + - in: path + name: name + required: true + schema: + type: string + - description: Strong config ETag returned by the latest settings read + in: header + name: If-Match + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceCheckResponse" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "412": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "502": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Run a synthetic people inference provider check + tags: + - API + /api/v1/settings/people-inference/providers/{name}/consent: + post: + operationId: consentSettingsPeopleInferenceProvider + parameters: + - in: path + name: name + required: true + schema: + type: string + - description: Strong config ETag returned by the latest settings read + in: header + name: If-Match + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceConsentRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceSettingsResponse" + description: OK + headers: + ETag: + description: Strong content hash for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "412": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Grant exact people inference consent + tags: + - API + /api/v1/settings/people-inference/providers/{name}/key: + delete: + operationId: deleteSettingsPeopleInferenceKey + parameters: + - in: path + name: name + required: true + schema: + type: string + - description: Opaque revision for this people provider credential + in: header + name: If-Match + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceSettingsResponse" + description: OK + headers: + ETag: + description: Strong content hash for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "412": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Clear a stored people inference API key + tags: + - API + put: + operationId: putSettingsPeopleInferenceKey + parameters: + - in: path + name: name + required: true + schema: + type: string + - description: Opaque revision for this people provider credential + in: header + name: If-Match + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceKeyWriteRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceSettingsResponse" + description: OK + headers: + ETag: + description: Strong content hash for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "412": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Set a write-only people inference API key + tags: + - API + /api/v1/settings/people-inference/providers/{name}/revoke: + post: + operationId: revokeSettingsPeopleInferenceProvider + parameters: + - in: path + name: name + required: true + schema: + type: string + - description: Strong config ETag returned by the latest settings read + in: header + name: If-Match + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceSettingsResponse" + description: OK + headers: + ETag: + description: Strong content hash for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "412": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Revoke exact people inference consent + tags: + - API + /api/v1/settings/people-inference/select: + post: + operationId: selectSettingsPeopleInference + parameters: + - description: Strong config ETag returned by the latest settings read + in: header + name: If-Match + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceSelectionRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceSettingsResponse" + description: OK + headers: + ETag: + description: Strong content hash for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "412": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "422": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Select a checked and consented people inference provider + tags: + - API /api/v1/settings/person-enrichment/providers/{name}: put: operationId: putSettingsPersonEnrichmentProvider diff --git a/cmd/msgvault-codex-bridge/main.go b/cmd/msgvault-codex-bridge/main.go new file mode 100644 index 000000000..ba616f9d4 --- /dev/null +++ b/cmd/msgvault-codex-bridge/main.go @@ -0,0 +1,52 @@ +//go:build linux + +// msgvault-codex-bridge provides a loopback HTTP proxy endpoint inside the +// Codex network namespace. It forwards bytes to the daemon's Unix CONNECT +// policy service; only that service can open an upstream connection. +package main + +import ( + "io" + "net" + "os" + "os/exec" +) + +func main() { + listener, err := net.Listen("tcp", "127.0.0.1:3128") + if err != nil { + os.Exit(1) + } + go func() { + for { + client, err := listener.Accept() + if err != nil { + return + } + go relay(client) + } + }() + command := exec.Command("/codex", os.Args[1:]...) //nolint:gosec // The launcher fixes /codex and validates the app-server arguments. + command.Stdin, command.Stdout, command.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := command.Run(); err != nil { + os.Exit(1) + } +} + +func relay(client net.Conn) { + defer func() { _ = client.Close() }() + upstream, err := net.Dial("unix", "/work/.proxy.sock") + if err != nil { + return + } + defer func() { _ = upstream.Close() }() + done := make(chan struct{}) + go func() { + _, _ = io.Copy(upstream, client) + close(done) + }() + _, _ = io.Copy(client, upstream) + _ = client.Close() + _ = upstream.Close() + <-done +} diff --git a/cmd/msgvault/cmd/person_provider.go b/cmd/msgvault/cmd/person_provider.go index a98b04074..d989155c1 100644 --- a/cmd/msgvault/cmd/person_provider.go +++ b/cmd/msgvault/cmd/person_provider.go @@ -10,6 +10,7 @@ import ( "io" "net/http" "os" + "path/filepath" "reflect" "slices" "strings" @@ -209,10 +210,11 @@ func defaultPersonProviderCommandDeps() personProviderCommandDeps { return st, func() { _ = st.Close() }, nil }, newChecker: func(config peoplesweep.Config, st personProviderStore) (personProviderChecker, error) { - registry, err := peoplesweep.NewDriverRegistry( + registry, err := peoplesweep.NewDriverRegistryWithCodexAuthHome( http.DefaultClient, peoplesweep.NewCodexCommandStarter(), peoplesweep.NewReleasedCodexIsolationGate(), + personProviderCodexAuthHome(), ) if err != nil { return nil, err @@ -236,14 +238,18 @@ func defaultPersonProviderCommandDeps() personProviderCommandDeps { ) }, newCodexClient: func(config peoplesweep.Config) (personProviderCodexClient, error) { + if !peoplesweep.CodexReleaseAvailable() { + return nil, peoplesweep.ErrCodexIsolationUnreleased + } _, provider, err := config.ActiveProviderConfig() if err != nil { return nil, err } - registry, err := peoplesweep.NewDriverRegistry( + registry, err := peoplesweep.NewDriverRegistryWithCodexAuthHome( http.DefaultClient, peoplesweep.NewCodexCommandStarter(), peoplesweep.NewReleasedCodexIsolationGate(), + personProviderCodexAuthHome(), ) if err != nil { return nil, err @@ -311,6 +317,13 @@ func defaultPersonProviderCommandDeps() personProviderCommandDeps { } } +func personProviderCodexAuthHome() string { + if cfg == nil { + return "" + } + return filepath.Join(cfg.TokensDir(), "people-codex") +} + func newPersonProviderCommand(deps personProviderCommandDeps) *cobra.Command { provider := &cobra.Command{ Use: personProviderCommandName, @@ -318,6 +331,7 @@ func newPersonProviderCommand(deps personProviderCommandDeps) *cobra.Command { } provider.AddCommand( newPersonProviderAddCommand(deps), + newPersonProviderCodexEnrollCommand(defaultCodexEnrollDeps()), newPersonProviderSetCommand(deps), newPersonProviderRemoveCommand(deps), newPersonProviderListCommand(deps), diff --git a/cmd/msgvault/cmd/person_provider_codex_enroll.go b/cmd/msgvault/cmd/person_provider_codex_enroll.go new file mode 100644 index 000000000..fdb30c544 --- /dev/null +++ b/cmd/msgvault/cmd/person_provider_codex_enroll.go @@ -0,0 +1,256 @@ +package cmd + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "slices" + "strings" + "time" + + "github.com/spf13/cobra" + "go.kenn.io/msgvault/internal/peoplesweep" + "go.kenn.io/msgvault/internal/tui" +) + +type codexEnrollDeps struct { + openBackend func(context.Context) (tui.PeopleInferenceBackend, func(), error) + isTerminal func(*cobra.Command) bool + pollInterval time.Duration +} + +type codexEnrollOptions struct { + model string + reasoningEffort string + retentionPosture string + trainingPosture string + allowedSources []string + sourceSince string + sourceUntil string + allowSensitive bool + yes bool +} + +func defaultCodexEnrollDeps() codexEnrollDeps { + return codexEnrollDeps{ + openBackend: func(ctx context.Context) (tui.PeopleInferenceBackend, func(), error) { + if !peoplesweep.CodexReleaseAvailable() { + return nil, nil, peoplesweep.ErrCodexIsolationUnreleased + } + client, _, err := OpenHTTPStore(ctx) + if err != nil { + return nil, nil, err + } + return newTUISettingsBackend(client), func() { _ = client.Close() }, nil + }, + isTerminal: commandStdinIsTerminal, + pollInterval: time.Second, + } +} + +func newPersonProviderCodexEnrollCommand(deps codexEnrollDeps) *cobra.Command { + var options codexEnrollOptions + command := &cobra.Command{ + Use: "enroll-codex ", + Short: "Create and select a Codex people provider through the daemon", + Args: exactPersonProviderNameArgs, + RunE: func(command *cobra.Command, args []string) error { + return runPersonProviderCodexEnroll(command, deps, args[0], options) + }, + } + flags := command.Flags() + flags.StringVar(&options.model, "model", "", "Codex model ID (prompt when omitted)") + flags.StringVar(&options.reasoningEffort, "reasoning-effort", "", "Supported reasoning effort (prompt when omitted)") + flags.StringVar(&options.retentionPosture, "retention-posture", "", "Operator retention assertion") + flags.StringVar(&options.trainingPosture, "training-posture", "", "Operator training assertion") + flags.StringSliceVar(&options.allowedSources, "source", nil, "Archive source class (repeatable)") + flags.StringVar(&options.sourceSince, "source-since", "", "Earliest disclosed source date (YYYY-MM-DD)") + flags.StringVar(&options.sourceUntil, "source-until", "", "Latest disclosed source date (YYYY-MM-DD)") + flags.BoolVar(&options.allowSensitive, "allow-sensitive", false, "Explicitly allow or exclude sensitive archive content") + flags.BoolVar(&options.yes, "yes", false, "Confirm the displayed check disclosure and select the profile") + return command +} + +func runPersonProviderCodexEnroll( + command *cobra.Command, deps codexEnrollDeps, name string, options codexEnrollOptions, +) error { + if deps.isTerminal == nil || !deps.isTerminal(command) { + return errors.New("codex enrollment requires a terminal; noninteractive commands never start device login") + } + if !command.Flags().Changed("allow-sensitive") || strings.TrimSpace(options.retentionPosture) == "" || + strings.TrimSpace(options.trainingPosture) == "" || len(options.allowedSources) == 0 { + return errors.New("codex enrollment requires --retention-posture, --training-posture, --source, and --allow-sensitive=true|false") + } + for _, source := range options.allowedSources { + if !slices.Contains([]string{"conversation_text", "meeting_text", "document_text"}, source) { + return fmt.Errorf("unsupported source class %q", source) + } + } + since, err := time.Parse("2006-01-02", options.sourceSince) + if err != nil { + return errors.New("--source-since must be a valid YYYY-MM-DD date") + } + if options.sourceUntil != "" { + until, err := time.Parse("2006-01-02", options.sourceUntil) + if err != nil || until.Before(since) { + return errors.New("--source-until must be a valid date on or after --source-since") + } + } + if deps.openBackend == nil { + return errors.New("daemon people enrollment client is unavailable") + } + backend, closeBackend, err := deps.openBackend(command.Context()) + if err != nil { + return err + } + if closeBackend != nil { + defer closeBackend() + } + if backend == nil { + return errors.New("daemon people enrollment client is unavailable") + } + ctx := command.Context() + login, err := backend.StartCodexLogin(ctx, name) + if err != nil { + return err + } + sessionActive := true + defer func() { + if sessionActive { + cancelCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = backend.CancelCodexLogin(cancelCtx, login.SessionID) + } + }() + out := command.OutOrStdout() + _, _ = fmt.Fprintf(out, "Verification URL: %s\nUser code: %s\nLocal deadline: %s\n", + login.URL, login.Code, login.Deadline.UTC().Format(time.RFC3339)) + interval := deps.pollInterval + if interval <= 0 { + interval = time.Second + } + for { + if !login.Deadline.IsZero() && time.Now().After(login.Deadline) { + return errors.New("codex device login reached its local deadline") + } + poll, err := backend.PollCodexLogin(ctx, login.SessionID) + if err != nil { + return err + } + if poll.Complete { + break + } + timer := time.NewTimer(interval) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } + models, err := backend.ListCodexModels(ctx, login.SessionID) + if err != nil { + return err + } + reader := bufio.NewReader(command.InOrStdin()) + model, effort, err := chooseCodexEnrollmentModel(out, reader, models, options.model, options.reasoningEffort) + if err != nil { + return err + } + profile, err := backend.SaveCodexProfile(ctx, login.SessionID, tui.CodexProfileRequest{ + Name: name, Model: model, ReasoningEffort: effort, + RetentionPosture: strings.TrimSpace(options.retentionPosture), + TrainingPosture: strings.TrimSpace(options.trainingPosture), + AllowedSources: append([]string(nil), options.allowedSources...), + SourceSince: options.sourceSince, SourceUntil: options.sourceUntil, + AllowSensitive: options.allowSensitive, + }) + if err != nil { + return err + } + sessionActive = false // Profile creation consumes the daemon login draft. + disclosure, err := backend.CheckCodexProfile(ctx, profile) + if err != nil { + return err + } + _, _ = fmt.Fprintf(out, "Synthetic check passed for %s (fingerprint %s).\n%s\n", + profile, disclosure.Fingerprint, disclosure.Text) + if !options.yes { + confirmed, err := promptCodexEnrollmentYes(reader, out, "Grant consent and select this profile? [y/N]: ") + if err != nil { + return err + } + if !confirmed { + return errors.New("consent declined; profile was saved but not selected") + } + } + if err := backend.ConsentCodexProfile(ctx, disclosure.Profile, disclosure.Fingerprint); err != nil { + return err + } + if err := backend.SelectCodexProfile(ctx, profile); err != nil { + return err + } + status, err := backend.LoadPeopleInferenceStatus(ctx) + if err != nil { + return err + } + _, _ = fmt.Fprintf(out, "Selected profile: %s\nConfigured: %s Running: %s\n", + profile, status.Configured, status.Running) + if status.PendingRestart { + _, _ = fmt.Fprintln(out, "Restart the daemon to use the selected profile.") + } + return nil +} + +func chooseCodexEnrollmentModel( + out io.Writer, reader *bufio.Reader, models []tui.CodexModelChoice, modelID, effort string, +) (string, string, error) { + if len(models) == 0 { + return "", "", errors.New("signed-in Codex account returned no models") + } + if modelID == "" { + _, _ = fmt.Fprintln(out, "Available Codex models:") + for _, model := range models { + _, _ = fmt.Fprintf(out, " %s (reasoning: %s)\n", model.ID, strings.Join(model.ReasoningEfforts, ", ")) + } + _, _ = fmt.Fprint(out, "Model ID: ") + answer, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", "", fmt.Errorf("read model choice: %w", err) + } + modelID = strings.TrimSpace(answer) + } + for _, model := range models { + if model.ID != modelID { + continue + } + if effort == "" { + _, _ = fmt.Fprintf(out, "Reasoning effort for %s (%s; default %s): ", + model.ID, strings.Join(model.ReasoningEfforts, ", "), model.DefaultReasoningEffort) + answer, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", "", fmt.Errorf("read reasoning effort: %w", err) + } + effort = strings.TrimSpace(answer) + if effort == "" { + effort = model.DefaultReasoningEffort + } + } + if !slices.Contains(model.ReasoningEfforts, effort) { + return "", "", fmt.Errorf("reasoning effort %q is unavailable for model %q", effort, modelID) + } + return modelID, effort, nil + } + return "", "", fmt.Errorf("model %q is unavailable for the signed-in Codex account", modelID) +} + +func promptCodexEnrollmentYes(reader *bufio.Reader, out io.Writer, prompt string) (bool, error) { + _, _ = fmt.Fprint(out, prompt) + answer, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return false, fmt.Errorf("read consent answer: %w", err) + } + return strings.EqualFold(strings.TrimSpace(answer), "y") || strings.EqualFold(strings.TrimSpace(answer), "yes"), nil +} diff --git a/cmd/msgvault/cmd/person_provider_codex_enroll_test.go b/cmd/msgvault/cmd/person_provider_codex_enroll_test.go new file mode 100644 index 000000000..856ec9162 --- /dev/null +++ b/cmd/msgvault/cmd/person_provider_codex_enroll_test.go @@ -0,0 +1,226 @@ +package cmd + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/peoplesweep" + "go.kenn.io/msgvault/internal/tui" +) + +func TestPersonProviderCodexEnrollRejectsNoninteractiveBeforeLogin(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + opened := false + command := newPersonProviderCodexEnrollCommand(codexEnrollDeps{ + isTerminal: func(*cobra.Command) bool { return false }, + openBackend: func(context.Context) (tui.PeopleInferenceBackend, func(), error) { + opened = true + return nil, nil, nil + }, + }) + command.SetArgs([]string{ + "codex-profile", "--source", "conversation_text", "--source-since", "2025-01-01", + "--retention-posture", "operator assertion", "--training-posture", "operator assertion", + "--allow-sensitive=false", "--yes", + }) + command.SetIn(strings.NewReader("")) + command.SetOut(&bytes.Buffer{}) + command.SetErr(&bytes.Buffer{}) + err := command.Execute() + require.Error(err) + assert.Contains(err.Error(), "terminal") + assert.False(opened) +} + +func TestPersonProviderCodexEnrollRejectsBlankPolicyBeforeLogin(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + opened := false + command := newPersonProviderCodexEnrollCommand(codexEnrollDeps{ + isTerminal: func(*cobra.Command) bool { return true }, + openBackend: func(context.Context) (tui.PeopleInferenceBackend, func(), error) { + opened = true + return nil, nil, nil + }, + }) + command.SetArgs([]string{ + "codex-profile", "--source", "conversation_text", "--source-since", "2025-01-01", + "--retention-posture", " ", "--training-posture", "operator assertion", "--allow-sensitive=false", + }) + command.SetIn(strings.NewReader("")) + command.SetOut(&bytes.Buffer{}) + command.SetErr(&bytes.Buffer{}) + err := command.Execute() + require.Error(err) + assert.Contains(err.Error(), "retention-posture") + assert.False(opened) +} + +func TestPersonProviderCodexEnrollCompletesDaemonJourney(t *testing.T) { + testPersonProviderCodexEnrollDaemonJourney(t, "y\n", true) +} + +func TestPersonProviderCodexEnrollDeclinedConsentLeavesSavedProfileUnselected(t *testing.T) { + testPersonProviderCodexEnrollDaemonJourney(t, "n\n", false) +} + +func testPersonProviderCodexEnrollDaemonJourney(t *testing.T, consentAnswer string, wantSelected bool) { + t.Helper() + assert := assert.New(t) + require := require.New(t) + var calls []string + saved, checked, consented, selected := false, false, false, false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + writeStatus := func() { + profiles := []map[string]any{} + if saved { + profiles = append(profiles, map[string]any{ + "name": "codex-profile", "protocol": "codex_app_server", "model": "gpt-test", + "output_mode": "native_json_schema", "credential_source": "none", + "allowed_sources": []string{"conversation_text"}, "source_since": "2025-01-01", + "retention_posture": "operator assertion: no retention", + "training_posture": "operator assertion: no training", + "fingerprint": "fp-1", "checked": checked, "consent_active": consented, + }) + } + status := map[string]any{"profiles": profiles, "running_name": "old-profile", "pending_restart": selected} + if selected { + status["configured_name"] = "codex-profile" + } + assert.NoError(json.NewEncoder(w).Encode(status)) + } + switch r.Method + " " + r.URL.Path { + case "POST /api/v1/settings/people-inference/codex/login": + var body map[string]any + assert.NoError(json.NewDecoder(r.Body).Decode(&body)) + assert.Equal("codex-profile", body["name"]) + _, _ = w.Write([]byte(`{"session_id":"session-1","verification_url":"https://example.test/device","user_code":"ABCD-EFGH","local_deadline":"2099-09-23T12:05:00Z"}`)) + case "GET /api/v1/settings/people-inference/codex/login/session-1": + _, _ = w.Write([]byte(`{"state":"complete"}`)) + case "GET /api/v1/settings/people-inference/codex/login/session-1/models": + _, _ = w.Write([]byte(`{"models":[{"id":"gpt-test","display_name":"Test model","default_reasoning_effort":"medium","supported_efforts":["low","medium"]}]}`)) + case "GET /api/v1/settings/people-inference": + w.Header().Set("ETag", `"config-1"`) + writeStatus() + case "PUT /api/v1/settings/people-inference/codex/login/session-1/profile": + assert.Equal(`"config-1"`, r.Header.Get("If-Match")) + var body map[string]any + assert.NoError(json.NewDecoder(r.Body).Decode(&body)) + assert.Equal("gpt-test", body["model"]) + assert.Equal("low", body["reasoning_effort"]) + assert.Equal(false, body["allow_sensitive"]) + saved = true + writeStatus() + case "POST /api/v1/settings/people-inference/providers/codex-profile/check": + checked = true + _, _ = w.Write([]byte(`{"ok":true,"fingerprint":"fp-1","model":"gpt-test","usage":{}}`)) + case "POST /api/v1/settings/people-inference/providers/codex-profile/consent": + var body map[string]any + assert.NoError(json.NewDecoder(r.Body).Decode(&body)) + assert.Equal("fp-1", body["fingerprint"]) + assert.Equal(true, body["confirmed"]) + consented = true + writeStatus() + case "POST /api/v1/settings/people-inference/select": + var body map[string]any + assert.NoError(json.NewDecoder(r.Body).Decode(&body)) + assert.Equal("codex-profile", body["name"]) + selected = true + writeStatus() + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + command := newPersonProviderCodexEnrollCommand(codexEnrollDeps{ + isTerminal: func(*cobra.Command) bool { return true }, + openBackend: func(context.Context) (tui.PeopleInferenceBackend, func(), error) { + return backend, func() {}, nil + }, + pollInterval: time.Millisecond, + }) + command.SetArgs([]string{ + "codex-profile", "--source", "conversation_text", "--source-since", "2025-01-01", + "--retention-posture", "operator assertion: no retention", "--training-posture", "operator assertion: no training", + "--allow-sensitive=false", "--model", "gpt-test", "--reasoning-effort", "low", + }) + command.SetIn(strings.NewReader(consentAnswer)) + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&bytes.Buffer{}) + err := command.Execute() + if wantSelected { + require.NoError(err) + } else { + require.ErrorContains(err, "consent declined; profile was saved but not selected") + } + assert.True(saved) + assert.True(checked) + assert.Equal(wantSelected, consented) + assert.Equal(wantSelected, selected) + assert.Contains(output.String(), "https://example.test/device") + assert.Contains(output.String(), "ABCD-EFGH") + assert.Contains(output.String(), "fp-1") + assert.Contains(output.String(), "codex-profile") + assert.NotContains(strings.Join(calls, "\n"), "DELETE /api/v1/settings/people-inference/codex/login/session-1") + wantCalls := []string{ + "POST /api/v1/settings/people-inference/codex/login", + "GET /api/v1/settings/people-inference/codex/login/session-1", + "GET /api/v1/settings/people-inference/codex/login/session-1/models", + "GET /api/v1/settings/people-inference", + "PUT /api/v1/settings/people-inference/codex/login/session-1/profile", + "GET /api/v1/settings/people-inference", + "POST /api/v1/settings/people-inference/providers/codex-profile/check", + "GET /api/v1/settings/people-inference", + } + if wantSelected { + wantCalls = append(wantCalls, + "GET /api/v1/settings/people-inference", + "POST /api/v1/settings/people-inference/providers/codex-profile/consent", + "GET /api/v1/settings/people-inference", + "POST /api/v1/settings/people-inference/select", + "GET /api/v1/settings/people-inference", + ) + } else { + assert.NotContains(strings.Join(calls, "\n"), "POST /api/v1/settings/people-inference/providers/codex-profile/consent") + assert.NotContains(strings.Join(calls, "\n"), "POST /api/v1/settings/people-inference/select") + } + assert.Equal(wantCalls, calls) +} + +func TestChooseCodexEnrollmentModelPromptsForAvailableChoice(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + models := []tui.CodexModelChoice{{ID: "gpt-test", DefaultReasoningEffort: "medium", ReasoningEfforts: []string{"low", "medium"}}} + var output bytes.Buffer + model, effort, err := chooseCodexEnrollmentModel(&output, bufio.NewReader(strings.NewReader("gpt-test\nlow\n")), models, "", "") + require.NoError(err) + assert.Equal("gpt-test", model) + assert.Equal("low", effort) + assert.Contains(output.String(), "gpt-test") + assert.Contains(output.String(), "low, medium") +} + +func TestCodexProductionClientsRefuseUnreleasedBuild(t *testing.T) { + if peoplesweep.CodexReleaseAvailable() { + t.Skip("Codex release has been approved") + } + _, err := defaultPersonProviderCommandDeps().newCodexClient(peoplesweep.Config{}) + require.ErrorIs(t, err, peoplesweep.ErrCodexIsolationUnreleased) + _, _, err = defaultCodexEnrollDeps().openBackend(t.Context()) + require.ErrorIs(t, err, peoplesweep.ErrCodexIsolationUnreleased) +} diff --git a/cmd/msgvault/cmd/person_provider_setup.go b/cmd/msgvault/cmd/person_provider_setup.go index 400174ecd..8a7c37061 100644 --- a/cmd/msgvault/cmd/person_provider_setup.go +++ b/cmd/msgvault/cmd/person_provider_setup.go @@ -42,6 +42,7 @@ type personProviderCreateCredentialStore interface { type personProviderAddOptions struct { custom bool + presetID string protocol string endpoint string model string @@ -92,7 +93,7 @@ func (o personProviderAddOptions) explicitTransport() bool { // needsCatalog reports whether add must fetch the models.dev catalog: only // when a transport field is missing or a catalog price hint was requested. func (o personProviderAddOptions) needsCatalog() bool { - return !o.custom && (!o.explicitTransport() || o.acceptCatalogPrices) + return o.presetID == "" && !o.custom && (!o.explicitTransport() || o.acceptCatalogPrices) } func defaultPersonProviderSetupDeps() personProviderSetupDeps { @@ -150,6 +151,7 @@ func newPersonProviderAddCommand(deps personProviderCommandDeps) *cobra.Command } flags := command.Flags() flags.BoolVar(&options.custom, "custom", false, "Skip public catalog suggestions") + flags.StringVar(&options.presetID, "provider", "", "First-party provider preset: openai, openrouter, or venice") flags.StringVar(&options.protocol, "protocol", "", "Explicit protocol identifier") flags.StringVar(&options.endpoint, "endpoint", "", "Explicit provider endpoint") flags.StringVar(&options.model, "model", "", "Explicit provider model identifier") @@ -386,19 +388,37 @@ func runPersonProviderAdd( } func personProviderCandidate(options personProviderAddOptions) (peoplesweep.ProviderConfig, error) { - if options.protocol == "" || options.endpoint == "" || options.model == "" || options.auth == "" || + if (options.presetID == "" && (options.protocol == "" || options.endpoint == "" || options.auth == "")) || options.model == "" || options.retentionPosture == "" || options.trainingPosture == "" || len(options.allowedSources) == 0 || options.sourceSince == "" { - return peoplesweep.ProviderConfig{}, errors.New("protocol, endpoint, model, auth, retention, training, source, and source-since are required") - } - candidate := peoplesweep.ProviderConfig{ - Protocol: peoplesweep.Protocol(options.protocol), Endpoint: options.endpoint, - Model: options.model, Auth: peoplesweep.AuthScheme(options.auth), - RetentionPosture: options.retentionPosture, TrainingPosture: options.trainingPosture, - SourceSince: options.sourceSince, SourceUntil: options.sourceUntil, - AllowSensitive: options.allowSensitive, ReasoningEffort: options.reasoningEffort, - ReasoningMode: options.reasoningMode, RequestTimeout: options.requestTimeout, + return peoplesweep.ProviderConfig{}, errors.New("provider or protocol, endpoint, auth, model, retention, training, source, and source-since are required") } + var candidate peoplesweep.ProviderConfig + if options.presetID != "" { + var err error + candidate, err = peoplesweep.PresetProviderConfig(options.presetID, options.model) + if err != nil { + return peoplesweep.ProviderConfig{}, err + } + if (options.protocol != "" && options.protocol != string(candidate.Protocol)) || + (options.endpoint != "" && options.endpoint != candidate.Endpoint) || + (options.auth != "" && options.auth != string(candidate.Auth)) { + return peoplesweep.ProviderConfig{}, fmt.Errorf("people provider preset %q cannot use a different protocol, endpoint, or auth", options.presetID) + } + } else { + candidate.Protocol = peoplesweep.Protocol(options.protocol) + candidate.Endpoint = options.endpoint + candidate.Model = options.model + candidate.Auth = peoplesweep.AuthScheme(options.auth) + } + candidate.RetentionPosture = options.retentionPosture + candidate.TrainingPosture = options.trainingPosture + candidate.SourceSince = options.sourceSince + candidate.SourceUntil = options.sourceUntil + candidate.AllowSensitive = options.allowSensitive + candidate.ReasoningEffort = options.reasoningEffort + candidate.ReasoningMode = options.reasoningMode + candidate.RequestTimeout = options.requestTimeout for _, source := range options.allowedSources { candidate.AllowedSources = append(candidate.AllowedSources, peoplesweep.SourceClass(source)) } @@ -420,6 +440,9 @@ func personProviderCandidate(options personProviderAddOptions) (peoplesweep.Prov } func validatePersonProviderAddOptions(options personProviderAddOptions) error { + if options.presetID != "" && (options.custom || options.acceptCatalogPrices) { + return errors.New("--provider cannot be combined with --custom or --accept-catalog-prices") + } if options.protocol == string(peoplesweep.ProtocolCodexAppServer) { return errors.New("codex_app_server profiles are not created by person provider add: " + "generic onboarding requires an HTTP endpoint that codex_app_server forbids, and capability negotiation " + @@ -454,7 +477,7 @@ func resolvePersonProviderAddCandidate( options personProviderAddOptions, suggestions []peoplesweep.ProviderSuggestion, ) (peoplesweep.ProviderConfig, error) { - if options.custom || options.explicitTransport() { + if options.presetID != "" || options.custom || options.explicitTransport() { return personProviderCandidate(options) } selection, err := selectPersonProviderCatalogSuggestion(options, suggestions) diff --git a/cmd/msgvault/cmd/person_provider_setup_test.go b/cmd/msgvault/cmd/person_provider_setup_test.go index ce702ec69..dd483b5c4 100644 --- a/cmd/msgvault/cmd/person_provider_setup_test.go +++ b/cmd/msgvault/cmd/person_provider_setup_test.go @@ -276,6 +276,45 @@ func TestPersonProviderAddValidatesPolicyBeforeReadingCredentialOrNegotiating(t assert.NotContains(output, providerSetupSecretCanary) } +func TestPersonProviderPresetRejectsEndpointSwapBeforeCredential(t *testing.T) { + path, loaded := providerSetupConfigFile(t) + deps := providerSetupCommandDeps(t, path, loaded, nil) + var lookups, negotiations int + deps.setup.lookupEnv = func(string) (string, bool) { + lookups++ + return providerSetupSecretCanary, true + } + deps.setup.negotiate = func(context.Context, peoplesweep.ProviderConfig, peoplesweep.Credential) (peoplesweep.NegotiatedCapabilities, error) { + negotiations++ + return peoplesweep.NegotiatedCapabilities{}, nil + } + + _, err := executePersonProviderCommand(t, deps, + "add", "venice-bound", "--provider", "venice", "--model", "venice/model", + "--endpoint", "https://elsewhere.example.test/v1", "--credential-env", "VENICE_KEY", + "--retention-posture", "operator_asserted", "--training-posture", "operator_asserted", + "--source", "conversation_text", "--source-since", "2025-01-01", "--yes") + require.ErrorContains(t, err, "preset") + assert.Zero(t, lookups) + assert.Zero(t, negotiations) +} + +func TestPersonProviderPresetCandidateKeepsAssertionsExplicit(t *testing.T) { + assert := assert.New(t) + candidate, err := personProviderCandidate(personProviderAddOptions{ + presetID: "openrouter", model: "explicit/model", credentialEnv: "EXACT_ROUTER_KEY", + retentionPosture: "operator_asserted", trainingPosture: "operator_asserted", + allowedSources: []string{"conversation_text"}, sourceSince: "2025-01-01", + }) + require.NoError(t, err) + assert.Equal("openrouter", candidate.PresetID) + assert.Equal("https://openrouter.ai/api/v1", candidate.Endpoint) + assert.Equal(peoplesweep.CredentialEnv, candidate.Credential) + assert.Equal("EXACT_ROUTER_KEY", candidate.CredentialEnv) + assert.Equal("operator_asserted", candidate.RetentionPosture) + assert.Equal("explicit/model", candidate.Model) +} + func TestPersonProviderAddRejectsLocalOptionConflictsBeforeCatalogOrState(t *testing.T) { tests := []struct { name string diff --git a/cmd/msgvault/cmd/person_provider_test.go b/cmd/msgvault/cmd/person_provider_test.go index 6ecc00961..135d7700a 100644 --- a/cmd/msgvault/cmd/person_provider_test.go +++ b/cmd/msgvault/cmd/person_provider_test.go @@ -1785,7 +1785,7 @@ func commandCodexScript( ) func(*bufio.Reader, io.Writer) error { t.Helper() return func(reader *bufio.Reader, writer io.Writer) error { - for id, want := range []string{"initialize", operation} { + for step, want := range []string{"initialize", "initialized", operation} { line, err := reader.ReadBytes('\n') if err != nil { return fmt.Errorf("read command Codex request: %w", err) @@ -1799,14 +1799,24 @@ func commandCodexScript( return err } *methods = append(*methods, request.Method) - if request.Method != want || request.ID != int64(id+1) { + wantID := int64(0) + switch step { + case 0: + wantID = 1 + case 2: + wantID = 2 + } + if request.Method != want || request.ID != wantID { return errors.New("unexpected Codex command transcript") } - if operation == "account/login/start" && id == 1 { + if step == 1 { + continue + } + if operation == "account/login/start" && step == 2 { assert.Equal(t, "chatgptDeviceCode", request.Params["type"]) } response := map[string]any{"id": request.ID, "result": map[string]any{}} - if id == 1 { + if step == 2 { response["result"] = result } encoded, err := json.Marshal(response) @@ -1816,7 +1826,7 @@ func commandCodexScript( if _, err := writer.Write(append(encoded, '\n')); err != nil { return err } - if operation == "account/login/start" && id == 1 { + if operation == "account/login/start" && step == 2 { completed, err := json.Marshal(map[string]any{ "method": "account/login/completed", "params": map[string]any{"success": true, "loginId": result["loginId"]}, @@ -1873,7 +1883,7 @@ func TestPersonProviderLoginUsesDeviceCode(t *testing.T) { checks.Contains(output, "ABCD-1234") checks.Contains(output, "2026-08-23T12:30:00Z") checks.NotContains(output, "login-safe") - checks.Equal([]string{"initialize", "account/login/start"}, methods) + checks.Equal([]string{"initialize", "initialized", "account/login/start"}, methods) checks.Zero(opens.Load()) } @@ -1902,7 +1912,7 @@ func TestPersonProviderModelsListsSupportedEfforts(t *testing.T) { checks.Contains(output, "Test Model") checks.Contains(output, "medium") checks.Contains(output, "low, medium") - checks.Equal([]string{"initialize", "model/list"}, methods) + checks.Equal([]string{"initialize", "initialized", "model/list"}, methods) checks.Zero(opens.Load()) } diff --git a/cmd/msgvault/cmd/serve_people_sweep.go b/cmd/msgvault/cmd/serve_people_sweep.go index b17b81b76..a1e998235 100644 --- a/cmd/msgvault/cmd/serve_people_sweep.go +++ b/cmd/msgvault/cmd/serve_people_sweep.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "os" + "path/filepath" "time" "github.com/google/uuid" @@ -111,9 +112,10 @@ func newProductionPersonSweepWorker( func newProductionStructuredRunner( cfg *config.Config, st *store.Store, ) (*peoplesweep.Runner, error) { - registry, err := peoplesweep.NewDriverRegistry( + registry, err := peoplesweep.NewDriverRegistryWithCodexAuthHome( http.DefaultClient, peoplesweep.NewCodexCommandStarter(), peoplesweep.NewReleasedCodexIsolationGate(), + filepath.Join(cfg.TokensDir(), "people-codex"), ) if err != nil { return nil, err diff --git a/cmd/msgvault/cmd/setup_lanes.go b/cmd/msgvault/cmd/setup_lanes.go index e2470d96b..ea81448e0 100644 --- a/cmd/msgvault/cmd/setup_lanes.go +++ b/cmd/msgvault/cmd/setup_lanes.go @@ -47,25 +47,22 @@ const ( // Recommended provider defaults. These are the values setup writes when // nothing is configured; every one of them remains settable per lane. - setupVoyageKeyEnv = "VOYAGE_API_KEY" // #nosec G101 -- environment variable name, not a credential. - setupOpenAIKeyEnv = "OPENAI_API_KEY" // #nosec G101 -- environment variable name, not a credential. - setupVoyageEndpoint = "https://api.voyageai.com/v1" - setupVoyageTextModel = "voyage-context-4" - setupVoyageTextDim = 1024 - setupOpenAIEndpoint = "https://api.openai.com/v1" - setupOpenAITextModel = "text-embedding-3-small" - setupOpenAITextDim = 1536 - setupOllamaTextModel = "nomic-embed-text" - setupOllamaTextDim = 768 - setupOllamaDocPrefix = "search_document: " - setupOllamaQueryPrefix = "search_query: " - setupOllamaMaxInput = 2000 - setupEmbedCron = "*/15 * * * *" - setupInferenceModel = "gpt-5.6-luna" - setupInferenceReasoning = "medium" - setupInferenceProfile = "openai" - setupOllamaProfile = "ollama" - setupPostureDeclared = "provider-declared" + setupVoyageKeyEnv = "VOYAGE_API_KEY" // #nosec G101 -- environment variable name, not a credential. + setupOpenAIKeyEnv = "OPENAI_API_KEY" // #nosec G101 -- environment variable name, not a credential. + setupVoyageEndpoint = "https://api.voyageai.com/v1" + setupVoyageTextModel = "voyage-context-4" + setupVoyageTextDim = 1024 + setupOpenAIEndpoint = "https://api.openai.com/v1" + setupOpenAITextModel = "text-embedding-3-small" + setupOpenAITextDim = 1536 + setupOllamaTextModel = "nomic-embed-text" + setupOllamaTextDim = 768 + setupOllamaDocPrefix = "search_document: " + setupOllamaQueryPrefix = "search_query: " + setupOllamaMaxInput = 2000 + setupEmbedCron = "*/15 * * * *" + setupOllamaProfile = "ollama" + setupPostureDeclared = "provider-declared" setupVoyageManifestName = "voyage-capabilities.json" setupMistralManifestName = "mistral-capabilities.json" @@ -620,14 +617,25 @@ func peopleInferenceLane(cfg *config.Config, env setupEnvironment) laneStatus { lane.State = laneStateOff if env.hasEnv(setupOpenAIKeyEnv) { lane.State = laneStatePending - lane.Reason = setupOpenAIKeyEnv + " present; setup can onboard the " + setupInferenceModel + " profile" - lane.Next = []string{"msgvault setup providers"} + lane.Reason = setupOpenAIKeyEnv + " is available; select an HTTP people inference provider with --provider and --model, or enroll Codex through the daemon" + lane.Next = []string{peopleInferencePresetSetupCommand("openai", setupOpenAIKeyEnv), peopleInferenceCodexEnrollCommand()} return lane } - lane.Reason = "needs " + setupOpenAIKeyEnv + " or a reachable local Ollama server, then `msgvault setup providers`" + lane.Reason = "choose an HTTP people inference provider with --provider and --model, enroll Codex through the daemon, or use a reachable local Ollama server" + lane.Next = []string{peopleInferencePresetSetupCommand("", ""), peopleInferenceCodexEnrollCommand()} return lane } +func peopleInferencePresetSetupCommand(provider, credentialEnv string) string { + return "msgvault setup providers --provider " + provider + " --model --credential-env " + credentialEnv + + " --retention-posture --training-posture --allow-sensitive= --yes" +} + +func peopleInferenceCodexEnrollCommand() string { + return "msgvault person provider enroll-codex --source conversation_text --source-since " + + " --retention-posture --training-posture --allow-sensitive=" +} + func activityLane(cfg *config.Config) laneStatus { lane := laneStatus{Lane: laneActivity, Label: "Contact activity (last contacted, cadence)"} if cfg.Activity.Schedule == "" { diff --git a/cmd/msgvault/cmd/setup_providers.go b/cmd/msgvault/cmd/setup_providers.go index b7a6e4e49..bef6b0ff6 100644 --- a/cmd/msgvault/cmd/setup_providers.go +++ b/cmd/msgvault/cmd/setup_providers.go @@ -34,9 +34,11 @@ const ( planActionOnboard = "onboard" // Consent gates: one explicit answer per hosted provider. - gateVoyage = "voyage" - gateMistral = "mistral" - gateOpenAI = "openai" + gateVoyage = "voyage" + gateMistral = "mistral" + gateOpenAI = "openai" + gateOpenRouter = "openrouter" + gateVenice = "venice" ollamaProbeTimeout = 2 * time.Second ollamaProbeMaxBody = 1 << 20 @@ -56,6 +58,10 @@ type setupProvidersOptions struct { trainingPosture string personRetentionPosture string personTrainingPosture string + providerID string + model string + credentialEnv string + apiKeyStdin bool } // ollamaProbeResult is what a local Ollama server reports about itself. @@ -304,7 +310,7 @@ func (p *setupProvidersPlan) gates() []string { } } ordered := []string{} - for _, gate := range []string{gateVoyage, gateMistral, gateOpenAI} { + for _, gate := range []string{gateVoyage, gateMistral, gateOpenAI, gateOpenRouter, gateVenice} { if seen[gate] { ordered = append(ordered, gate) } @@ -379,6 +385,20 @@ func (p *setupProvidersPlan) mergedEdits() []config.TableEdit { } func validateSetupProvidersOptions(options setupProvidersOptions) error { + if options.providerID == "" && (options.model != "" || options.credentialEnv != "" || options.apiKeyStdin) { + return errors.New("--model, --credential-env, and --api-key-stdin require --provider") + } + if options.providerID != "" { + if _, err := peoplesweep.PresetProviderConfig(options.providerID, "validation-model"); err != nil { + return err + } + if strings.TrimSpace(options.model) == "" { + return errors.New("--model is required with --provider") + } + if options.apiKeyStdin && options.credentialEnv != "" { + return errors.New("--api-key-stdin and --credential-env are mutually exclusive") + } + } if options.documentRetention != documentindex.RetentionStandard && options.documentRetention != documentindex.RetentionZDR { return fmt.Errorf("--document-retention must be %q or %q", documentindex.RetentionStandard, documentindex.RetentionZDR) } @@ -683,8 +703,8 @@ func planPeopleInference( lane.Reason = "already enabled" return lane, nil } - if !options.allowSensitive && (detection.openAIKey || - (detection.ollama.Reachable && detection.ollamaLoopback && detection.ollama.hasModel(loaded.Chat.Model))) { + if options.providerID == "" && !options.allowSensitive && + detection.ollama.Reachable && detection.ollamaLoopback && detection.ollama.hasModel(loaded.Chat.Model) { lane.Action = planActionPending lane.Reason = "people sweep requires --allow-sensitive: sensitive archive excerpts may be sent to the selected inference provider and used to infer sensitive personal attributes" lane.next = []string{"msgvault setup providers --allow-sensitive"} @@ -701,22 +721,35 @@ func planPeopleInference( allowedSources: sources, sourceSince: since, allowSensitive: options.allowSensitive, requestTimeout: time.Minute, confirmed: true, } - switch { - case detection.openAIKey: - if _, exists := loaded.People.Sweep.Providers[setupInferenceProfile]; exists { - lane.Action = planActionSkip - lane.Provider = setupInferenceProfile - lane.Reason = "profile exists but the sweep is off; run `msgvault person provider consent " + - setupInferenceProfile + " --yes` and `msgvault person provider use " + setupInferenceProfile + "`" + if options.providerID != "" { + preset, err := peoplesweep.PresetProviderConfig(options.providerID, options.model) + if err != nil { + lane.Action, lane.Reason = planActionSkip, err.Error() + return lane, nil + } + base.custom = false + base.presetID = options.providerID + base.protocol, base.endpoint, base.auth = string(preset.Protocol), preset.Endpoint, string(preset.Auth) + base.model, base.credentialEnv, base.apiKeyStdin = options.model, options.credentialEnv, options.apiKeyStdin + gate := options.providerID + if _, exists := loaded.People.Sweep.Providers[gate]; exists { + lane.Action, lane.Provider = planActionSkip, gate + lane.Reason = "profile exists but the sweep is off; review and select it with `msgvault person provider use " + gate + "`" return lane, nil } - base.endpoint, base.model, base.auth = setupOpenAIEndpoint, setupInferenceModel, string(peoplesweep.AuthBearer) - base.credentialEnv, base.reasoningEffort = setupOpenAIKeyEnv, setupInferenceReasoning - lane.Action, lane.Provider, lane.Model, lane.Gate = planActionOnboard, setupInferenceProfile, setupInferenceModel, gateOpenAI - lane.Reason = fmt.Sprintf("openai_chat profile %q at %s reasoning; sensitive archive excerpts from %s since %s may be sent to OpenAI and used to infer sensitive personal attributes; extraction runs for tracked people only", - setupInferenceProfile, setupInferenceReasoning, strings.Join(sources, ", "), since) + lane.Action, lane.Provider, lane.Model, lane.Gate = planActionOnboard, gate, options.model, gate + lane.Reason = "selected " + gate + " people inference profile; synthetic check and separate disclosure consent are required" lane.next = []string{"msgvault person track "} - return lane, &setupInferencePlan{name: setupInferenceProfile, options: base, gate: gateOpenAI} + return lane, &setupInferencePlan{name: gate, options: base, gate: gate} + } + switch { + case detection.openAIKey: + lane.Action = planActionSkip + command := peopleInferencePresetSetupCommand("openai", setupOpenAIKeyEnv) + codexCommand := peopleInferenceCodexEnrollCommand() + lane.Reason = setupOpenAIKeyEnv + " is available for embeddings; select people inference explicitly with `" + command + "`, or enroll Codex with `" + codexCommand + "` (requires a terminal)" + lane.next = []string{command, codexCommand} + return lane, nil case detection.ollama.Reachable && detection.ollamaLoopback && detection.ollama.hasModel(loaded.Chat.Model): if _, exists := loaded.People.Sweep.Providers[setupOllamaProfile]; exists { lane.Action = planActionSkip @@ -743,7 +776,10 @@ func planPeopleInference( "` or set [chat].model to an available chat model, then re-run setup" default: lane.Action = planActionSkip - lane.Reason = "needs " + setupOpenAIKeyEnv + " or a local Ollama server" + command := peopleInferencePresetSetupCommand("", "") + codexCommand := peopleInferenceCodexEnrollCommand() + lane.Reason = "choose an HTTP people inference provider with `" + command + "`, enroll Codex with `" + codexCommand + "` (requires a terminal), or run local Ollama" + lane.next = []string{command, codexCommand} } return lane, nil } @@ -786,10 +822,29 @@ func gateDisclosure(gate string, plan *setupProvidersPlan) string { " - document search query text, after `msgvault documents vectors consent --purpose queries --yes`") } if plan.inference != nil && plan.inference.gate == gateOpenAI { + choice := plan.inference.options lines = append(lines, " - bounded evidence packets of "+ - strings.Join(plan.inference.options.allowedSources, ", ")+ - " for tracked people ("+setupInferenceModel+"); a synthetic check request is sent now", - " - --allow-sensitive authorizes sending sensitive archive excerpts to OpenAI and inferring sensitive personal attributes") + strings.Join(choice.allowedSources, ", ")+ + " for tracked people ("+choice.model+") since "+choice.sourceSince+"; a synthetic check request is sent now", + " - recorded assertions: retention="+choice.retentionPosture+", training="+choice.trainingPosture) + if choice.allowSensitive { + lines = append(lines, " - --allow-sensitive authorizes sending sensitive archive excerpts to OpenAI and inferring sensitive personal attributes") + } else { + lines = append(lines, " - sensitive archive excerpts are excluded from provider packets") + } + } + case gateOpenRouter, gateVenice: + if plan.inference != nil && plan.inference.gate == gate { + choice := plan.inference.options + lines = append(lines, gate+" ("+choice.endpoint+") receives:", + " - a synthetic check request now; after consent, bounded evidence packets of "+ + strings.Join(choice.allowedSources, ", ")+" for tracked people ("+choice.model+") since "+choice.sourceSince, + " - recorded assertions: retention="+choice.retentionPosture+", training="+choice.trainingPosture) + if choice.allowSensitive { + lines = append(lines, " - sensitive archive excerpts and inferred personal attributes are allowed") + } else { + lines = append(lines, " - sensitive archive excerpts are excluded from provider packets") + } } } return strings.Join(lines, "\n") @@ -840,7 +895,7 @@ func newSetupProvidersCommand(deps setupProvidersDeps) *cobra.Command { var options setupProvidersOptions command := &cobra.Command{ Use: "providers", - Short: "Turn on the retrieval and people lanes the available API keys support, with recommended defaults", + Short: "Configure retrieval and people inference providers", Long: `Read the environment and configure every lane that is still unset: ` + setupVoyageKeyEnv + ` text search with Voyage contextual embeddings (conversation @@ -848,21 +903,28 @@ func newSetupProvidersCommand(deps setupProvidersDeps) *cobra.Command { people search, and the visual attachment lane once its probe manifest exists ` + setupOpenAIKeyEnv + ` text search on the OpenAI-compatible path when no Voyage key - is present, and the people sweep on ` + setupInferenceModel + ` + is present MISTRAL_API_KEY document attachment extraction, plus document vectors when a text lane is on (no keys) a local Ollama server at [chat].server when it is reachable -Hosted lanes never turn on from a key alone: setup asks once per provider, -writes the recommended values to config.toml, runs the people-provider -check and consent, and prints what is on, what is off, and why. Lanes that -are already configured are left alone, so re-running after adding a key -upgrades only that lane. Probe manifests are expected at +Choose a hosted people inference provider with --provider openai, openrouter, +or venice. Supply --model, a credential source, retention and training +assertions, and --allow-sensitive=true or --allow-sensitive=false. Setup checks +the named provider with a synthetic request before consent and selection. +For a Codex subscription, run msgvault person provider enroll-codex with a +profile name and explicit source scope and policy flags. Codex enrollment +requires a terminal and uses daemon device login; setup never starts the login +flow. + +Hosted lanes require confirmation. Setup prints the planned changes and asks +before writing them. Configured lanes keep their existing settings. Probe +manifests are expected at /` + setupVoyageManifestName + ` and /` + setupMistralManifestName + `. -The people sweep also requires --allow-sensitive: archive excerpts may contain -sensitive details and may be used to infer sensitive personal attributes. ---yes accepts provider prompts but does not grant this separate opt-in.`, +The people sweep can send archive excerpts and infer personal attributes. +--allow-sensitive controls whether sensitive excerpts may be included. +--yes accepts provider prompts; it does not choose a provider or a model.`, Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { return runSetupProviders(command, deps, options) @@ -882,6 +944,10 @@ sensitive details and may be used to infer sensitive personal attributes. "Retention assertion recorded for embedding and inference providers") flags.StringVar(&options.trainingPosture, "training-posture", setupPostureDeclared, "Training assertion recorded for embedding and inference providers") + flags.StringVar(&options.providerID, "provider", "", "People inference provider preset: openai, openrouter, or venice") + flags.StringVar(&options.model, "model", "", "Explicit people inference model ID") + flags.StringVar(&options.credentialEnv, "credential-env", "", "Read only this environment variable for people inference") + flags.BoolVar(&options.apiKeyStdin, "api-key-stdin", false, "Read the people inference API key from standard input") return command } @@ -889,6 +955,16 @@ func runSetupProviders(command *cobra.Command, deps setupProvidersDeps, options if err := validateSetupProvidersOptions(options); err != nil { return err } + if options.providerID != "" { + if !command.Flags().Changed("retention-posture") || !command.Flags().Changed("training-posture") || + !command.Flags().Changed("allow-sensitive") { + return errors.New("--provider requires explicit --retention-posture, --training-posture, and --allow-sensitive=true|false") + } + if options.credentialEnv == "" && !options.apiKeyStdin && + (deps.isTerminal == nil || !deps.isTerminal(command)) { + return errors.New("--provider requires --credential-env or --api-key-stdin outside a terminal") + } + } if deps.remoteConfigured != nil && deps.remoteConfigured() { return errors.New("setup providers cannot run against a configured remote daemon: it edits this machine's config.toml, which the remote daemon never reads; run it on the daemon host, or pass --local to configure a daemon on this machine") } @@ -904,6 +980,9 @@ func runSetupProviders(command *cobra.Command, deps setupProvidersDeps, options if err != nil { return err } + if options.providerID != "" && loaded.People.Sweep.Enabled { + return errors.New("people sweep is already enabled; use person provider add and person provider use to change providers") + } // Read saved assertions before config defaults turn an absent document // posture into "unknown". Only the corresponding explicit flag replaces // an existing assertion; inference still uses its own command defaults. diff --git a/cmd/msgvault/cmd/setup_providers_test.go b/cmd/msgvault/cmd/setup_providers_test.go index 35258823b..e220888f7 100644 --- a/cmd/msgvault/cmd/setup_providers_test.go +++ b/cmd/msgvault/cmd/setup_providers_test.go @@ -31,6 +31,15 @@ import ( const setupProvidersTestKey = "setup-providers-test-key" +func explicitOpenAISetupFlags() []string { + return []string{ + "--provider", "openai", "--model", "gpt-5.6-luna", + "--credential-env", setupOpenAIKeyEnv, + "--retention-posture", setupPostureDeclared, + "--training-posture", setupPostureDeclared, + } +} + // setupProvidersFixture is one operator machine: a real config file, a real // archive store, a fixed environment, and a fixed filesystem view for the // probe manifests. @@ -48,11 +57,12 @@ type setupProvidersFixture struct { func newSetupProvidersFixture(t *testing.T, content string) *setupProvidersFixture { t.Helper() + require := require.New(t) dir := t.TempDir() path := filepath.Join(dir, "config.toml") if content != "" { content = strings.ReplaceAll(content, "{{DIR}}", filepath.ToSlash(dir)) - require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + require.NoError(os.WriteFile(path, []byte(content), 0o600)) } return &setupProvidersFixture{ dir: dir, @@ -69,10 +79,11 @@ func newSetupProvidersFixture(t *testing.T, content string) *setupProvidersFixtu func (f *setupProvidersFixture) load(t *testing.T) *config.Config { t.Helper() + require := require.New(t) snapshot, err := config.ReadConfigFile(f.path) - require.NoError(t, err) + require.NoError(err) loaded, err := loadSetupConfig(snapshot, f.dir) - require.NoError(t, err) + require.NoError(err) return loaded } @@ -230,6 +241,8 @@ func TestSetupProvidersResolvesDisabledVisualCredential(t *testing.T) { } func TestSetupProvidersPreservesMissingCustomVisualManifest(t *testing.T) { + require := require.New(t) + assert := assert.New(t) fixture := newSetupProvidersFixture(t, setupProvidersMinimalConfig+` [vector.multimodal] @@ -238,7 +251,7 @@ capabilities_file = "{{DIR}}/custom-voyage.json" fixture.env[setupVoyageKeyEnv] = setupProvidersTestKey fixture.writeVisualManifest(t, filepath.Join(fixture.dir, setupVoyageManifestName), voyage.CapabilityQueryText) output, err := fixture.run(t, "providers", "--yes") - require.NoError(t, err, output) + require.NoError(err, output) loaded := fixture.load(t) custom := filepath.ToSlash(filepath.Join(fixture.dir, "custom-voyage.json")) assert.False(loaded.Vector.Multimodal.Enabled) @@ -312,6 +325,8 @@ func TestSetupDocumentConsentMatchesCompletePolicy(t *testing.T) { assert.True(t, setupConsentFromStore(t.Context(), c, st).Documents) for _, field := range []string{"provider", "model", "region", "retention", "training", "spool limit", "scope", "normalization", "missing manifest"} { t.Run(field, func(t *testing.T) { + assert := assert.New(t) + changed := *c documents := &changed.Attachments.Documents switch field { @@ -335,10 +350,10 @@ func TestSetupDocumentConsentMatchesCompletePolicy(t *testing.T) { changed.HomeDir = t.TempDir() } consent := setupConsentFromStore(t.Context(), &changed, st) - assert.False(t, consent.Documents) + assert.False(consent.Documents) lane := documentsLane(&changed, setupEnvironment{consent: consent, lookupEnv: func(string) (string, bool) { return setupProvidersTestKey, true }}) - assert.Equal(t, laneStatePending, lane.State) - assert.Equal(t, consentMissing, lane.Consent) + assert.Equal(laneStatePending, lane.State) + assert.Equal(consentMissing, lane.Consent) }) } _, err = st.RetireDocumentExtractionProfile(t.Context(), profile.ID) @@ -348,6 +363,7 @@ func TestSetupDocumentConsentMatchesCompletePolicy(t *testing.T) { func (f *setupProvidersFixture) personProviderDeps(t *testing.T) personProviderCommandDeps { t.Helper() + assert := assert.New(t) loaded := f.load(t) deps := localPersonProviderDeps(loaded.People.Sweep, f.store, f.checker) deps.readConfigFile = func() (config.ConfigFile, error) { return config.ReadConfigFile(f.path) } @@ -362,7 +378,7 @@ func (f *setupProvidersFixture) personProviderDeps(t *testing.T) personProviderC lookupEnv: f.lookupEnv, negotiate: func(_ context.Context, candidate peoplesweep.ProviderConfig, credential peoplesweep.Credential) (peoplesweep.NegotiatedCapabilities, error) { if credential.Scheme != peoplesweep.AuthNone { - assert.Equal(t, setupProvidersTestKey, credential.Value()) + assert.Equal(setupProvidersTestKey, credential.Value()) } return peoplesweep.NegotiatedCapabilities{ OutputMode: peoplesweep.OutputModeNativeJSONSchema, TokenLimitParameter: "max_completion_tokens", @@ -433,11 +449,12 @@ func (f *setupProvidersFixture) run(t *testing.T, args ...string) (string, error func (f *setupProvidersFixture) readConfig(t *testing.T) string { t.Helper() + require := require.New(t) content, err := os.ReadFile(f.path) if os.IsNotExist(err) { return "" } - require.NoError(t, err) + require.NoError(err) return string(content) } @@ -449,6 +466,8 @@ data_dir = "{{DIR}}/data" func TestSetupProvidersPreservesPosturesUnlessFlagsOverride(t *testing.T) { for name, override := range map[string]bool{"preserve": false, "override": true} { t.Run(name, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) fixture := newSetupProvidersFixture(t, setupProvidersMinimalConfig+` [vector.people] @@ -467,7 +486,7 @@ training_posture = "opted-out" peopleRetention, documentTraining = setupPostureDeclared, documentindex.TrainingDefaultOptOut } output, err := fixture.run(t, args...) - require.NoError(t, err, output) + require.NoError(err, output) loaded := fixture.load(t) assert.Equal(peopleRetention, loaded.Vector.People.RetentionPosture) assert.Equal("no_training", loaded.Vector.People.TrainingPosture) @@ -523,6 +542,8 @@ training_posture = %q func TestSetupProvidersCustomHostedEndpointNeedsExplicitConfiguration(t *testing.T) { for _, endpoint := range []string{"https://api.openai.com.example.test/v1", "https://localhost.example.test/v1", "https://embeddings.example.test/v1"} { t.Run(endpoint, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) fixture := newSetupProvidersFixture(t, setupProvidersMinimalConfig+fmt.Sprintf(` [vector] @@ -538,7 +559,7 @@ training_posture = "opted-out" `, endpoint)) before := fixture.readConfig(t) output, err := fixture.run(t, "providers", "--yes") - require.NoError(t, err, output) + require.NoError(err, output) assert.Equal(before, fixture.readConfig(t)) assert.False(fixture.load(t).Vector.People.Enabled) assert.False(fixture.load(t).Attachments.Documents.Index.Embeddings.Enabled) @@ -560,7 +581,10 @@ func TestEmbeddingProviderNameUsesURLHost(t *testing.T) { "https://api.openai.com@example.test/v1": "custom", "not a URL": "custom", "": "", } { - t.Run(endpoint, func(t *testing.T) { assert.Equal(t, want, embeddingProviderName(endpoint)) }) + t.Run(endpoint, func(t *testing.T) { + assert := assert.New(t) + assert.Equal(want, embeddingProviderName(endpoint)) + }) } } @@ -589,12 +613,14 @@ func TestSetupProvidersPostgresRequiresCompiledBackend(t *testing.T) { } func TestSetupStatusConfiguredVectorLanesRequireCompiledBackend(t *testing.T) { + require := require.New(t) + fixture := newSetupProvidersFixture(t, setupProvidersMinimalConfig) fixture.env[setupVoyageKeyEnv] = setupProvidersTestKey fixture.env["MISTRAL_API_KEY"] = setupProvidersTestKey fixture.writeVisualManifest(t, filepath.Join(fixture.dir, setupVoyageManifestName), voyage.CapabilityQueryText) output, err := fixture.run(t, "providers", "--yes") - require.NoError(t, err, output) + require.NoError(err, output) loaded := fixture.load(t) previous := cfg cfg = loaded @@ -622,21 +648,20 @@ func TestSetupStatusConfiguredVectorLanesRequireCompiledBackend(t *testing.T) { } } -func TestSetupProvidersRequiresSensitiveOptIn(t *testing.T) { +func TestSetupProvidersRequiresExplicitSensitiveDecision(t *testing.T) { assert := assert.New(t) require := require.New(t) fixture := newSetupProvidersFixture(t, setupProvidersMinimalConfig) fixture.env[setupOpenAIKeyEnv] = setupProvidersTestKey - output, err := fixture.run(t, "providers", "--yes", "--json") - require.NoError(err, output) + output, err := fixture.run(t, append([]string{"providers", "--yes", "--json"}, explicitOpenAISetupFlags()...)...) + require.ErrorContains(err, "--allow-sensitive") assert.False(fixture.load(t).People.Sweep.Enabled) - assert.NotContains(fixture.load(t).People.Sweep.Providers, setupInferenceProfile) + assert.NotContains(fixture.load(t).People.Sweep.Providers, "openai") assert.Zero(fixture.checker.calls.Load()) assert.Contains(output, "--allow-sensitive") - output, err = fixture.run(t, "providers", "--yes", "--allow-sensitive", "--json") + output, err = fixture.run(t, append([]string{"providers", "--yes", "--allow-sensitive", "--json"}, explicitOpenAISetupFlags()...)...) require.NoError(err, output) - assert.Contains(output, "sensitive archive excerpts") assert.EqualValues(1, fixture.checker.calls.Load()) profile, err := fixture.load(t).People.Sweep.Profile() require.NoError(err) @@ -654,7 +679,7 @@ func TestSetupStatusReportsMissingHostedCredentials(t *testing.T) { fixture.env[key] = setupProvidersTestKey } fixture.writeVisualManifest(t, filepath.Join(fixture.dir, setupVoyageManifestName), voyage.CapabilityQueryText) - _, err := fixture.run(t, "providers", "--yes", "--allow-sensitive") + _, err := fixture.run(t, append([]string{"providers", "--yes", "--allow-sensitive"}, explicitOpenAISetupFlags()...)...) require.NoError(err) for lane, key := range map[string]string{ laneTextSearch: setupVoyageKeyEnv, lanePersonSearch: setupVoyageKeyEnv, laneDocumentVectors: setupVoyageKeyEnv, @@ -828,13 +853,15 @@ func TestSetupReportsCheckStoredPeopleCredential(t *testing.T) { } func TestSetupStatusConsentGatedLanesRequireActiveConsent(t *testing.T) { + require := require.New(t) + fixture := newSetupProvidersFixture(t, setupProvidersMinimalConfig) for _, key := range []string{setupVoyageKeyEnv, "MISTRAL_API_KEY", setupOpenAIKeyEnv} { fixture.env[key] = setupProvidersTestKey } fixture.writeVisualManifest(t, filepath.Join(fixture.dir, setupVoyageManifestName), voyage.CapabilityQueryText) - output, err := fixture.run(t, "providers", "--yes", "--allow-sensitive") - require.NoError(t, err, output) + output, err := fixture.run(t, append([]string{"providers", "--yes", "--allow-sensitive"}, explicitOpenAISetupFlags()...)...) + require.NoError(err, output) loaded := fixture.load(t) for _, test := range []struct { consent *setupConsentState @@ -880,6 +907,8 @@ func TestSetupProvidersPreservesExplicitSchedules(t *testing.T) { {name: "sync disabled", toml: "run_after_sync = false", cron: setupEmbedCron}, } { t.Run(fmt.Sprintf("%s/manifest=%t", schedule.name, manifest), func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) fixture := newSetupProvidersFixture(t, setupProvidersMinimalConfig+ "\n[vector.embed.schedule]\n"+schedule.toml+ @@ -889,7 +918,7 @@ func TestSetupProvidersPreservesExplicitSchedules(t *testing.T) { fixture.writeVisualManifest(t, filepath.Join(fixture.dir, setupVoyageManifestName), voyage.CapabilityQueryText) } output, err := fixture.run(t, "providers", "--yes") - require.NoError(t, err, output) + require.NoError(err, output) loaded := fixture.load(t) assert.Equal(schedule.cron, loaded.Vector.Embed.Schedule.Cron) assert.Equal(schedule.runAfterSync, loaded.Vector.Embed.Schedule.RunAfterSync) @@ -1009,21 +1038,24 @@ func TestSetupProvidersMistralEnablesDocumentsAndVectors(t *testing.T) { } func TestSetupProvidersRejectsUnknownDocumentPostures(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + fixture := newSetupProvidersFixture(t, setupProvidersMinimalConfig) fixture.env["MISTRAL_API_KEY"] = setupProvidersTestKey _, err := fixture.run(t, "providers", "--yes", "--document-retention", "unknown") - require.ErrorContains(t, err, "--document-retention") - assert.Empty(t, strings.TrimSpace(strings.TrimPrefix(fixture.readConfig(t), strings.ReplaceAll(setupProvidersMinimalConfig, "{{DIR}}", filepath.ToSlash(fixture.dir))))) + require.ErrorContains(err, "--document-retention") + assert.Empty(strings.TrimSpace(strings.TrimPrefix(fixture.readConfig(t), strings.ReplaceAll(setupProvidersMinimalConfig, "{{DIR}}", filepath.ToSlash(fixture.dir))))) } -func TestSetupProvidersOpenAIFallbackOnboardsInference(t *testing.T) { +func TestSetupProvidersExplicitOpenAIOnboardsInference(t *testing.T) { assert := assert.New(t) require := require.New(t) fixture := newSetupProvidersFixture(t, setupProvidersMinimalConfig) fixture.env[setupOpenAIKeyEnv] = setupProvidersTestKey - output, err := fixture.run(t, "providers", "--yes", "--allow-sensitive") + output, err := fixture.run(t, append([]string{"providers", "--yes", "--allow-sensitive"}, explicitOpenAISetupFlags()...)...) require.NoError(err, output) loaded := fixture.load(t) @@ -1036,12 +1068,13 @@ func TestSetupProvidersOpenAIFallbackOnboardsInference(t *testing.T) { sweep := loaded.People.Sweep require.True(sweep.Enabled) - assert.Equal(setupInferenceProfile, sweep.Provider.Name) - profile := sweep.Providers[setupInferenceProfile] + assert.Equal("openai", sweep.Provider.Name) + profile := sweep.Providers["openai"] assert.Equal(peoplesweep.ProtocolOpenAIChat, profile.Protocol) assert.Equal(setupOpenAIEndpoint, profile.Endpoint) - assert.Equal(setupInferenceModel, profile.Model) - assert.Equal(setupInferenceReasoning, profile.ReasoningEffort) + assert.Equal("gpt-5.6-luna", profile.Model) + assert.Equal("openai", profile.PresetID) + assert.Empty(profile.ReasoningEffort) assert.Equal(peoplesweep.CredentialEnv, profile.Credential) assert.Equal(setupOpenAIKeyEnv, profile.CredentialEnv) assert.Equal("2025-01-01", profile.SourceSince) @@ -1071,7 +1104,195 @@ func TestSetupProvidersOpenAIFallbackOnboardsInference(t *testing.T) { inference := findLane(t, report, lanePeopleInference) assert.Equal(laneStateOn, inference.State) assert.Equal(consentActive, inference.Consent) - assert.Equal(setupInferenceModel, inference.Model) + assert.Equal("gpt-5.6-luna", inference.Model) +} + +func TestSetupProvidersSelectsExplicitHTTPPresets(t *testing.T) { + for _, test := range []struct { + provider, endpoint, credentialEnv string + }{ + {provider: "openrouter", endpoint: "https://openrouter.ai/api/v1", credentialEnv: "OPENROUTER_KEY"}, + {provider: "venice", endpoint: "https://api.venice.ai/api/v1", credentialEnv: "VENICE_KEY"}, + } { + t.Run(test.provider, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + fixture := newSetupProvidersFixture(t, setupProvidersMinimalConfig) + fixture.env[test.credentialEnv] = setupProvidersTestKey + model := test.provider + "/model" + output, err := fixture.run(t, "providers", "--provider", test.provider, "--model", model, + "--credential-env", test.credentialEnv, "--retention-posture", "operator_asserted", + "--training-posture", "operator_asserted", "--allow-sensitive", "--yes") + require.NoError(err, output) + sweep := fixture.load(t).People.Sweep + require.True(sweep.Enabled) + provider := sweep.Providers[test.provider] + assert.Equal(test.provider, provider.PresetID) + assert.Equal(test.endpoint, provider.Endpoint) + assert.Equal(model, provider.Model) + assert.Equal(test.credentialEnv, provider.CredentialEnv) + assert.Equal("operator_asserted", provider.RetentionPosture) + }) + } +} + +func TestSetupProvidersDoesNotInferPeopleProviderFromEmbeddingKey(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + fixture := newSetupProvidersFixture(t, setupProvidersMinimalConfig) + fixture.env[setupOpenAIKeyEnv] = setupProvidersTestKey + output, err := fixture.run(t, "providers", "--allow-sensitive", "--yes") + require.NoError(err, output) + loaded := fixture.load(t) + assert.True(loaded.Vector.Enabled) + assert.False(loaded.People.Sweep.Enabled) + assert.NotContains(loaded.People.Sweep.Providers, "openai") + status, err := fixture.run(t, "status", "--json") + require.NoError(err, status) + var report laneReport + require.NoError(json.Unmarshal([]byte(status), &report)) + assert.Contains(findLane(t, report, lanePeopleInference).Reason, "--provider") +} + +func TestSetupStatusPeopleInferenceHintsIncludeRequiredPresetFlags(t *testing.T) { + const codexNext = "msgvault person provider enroll-codex --source conversation_text --source-since --retention-posture --training-posture --allow-sensitive=" + for _, test := range []struct { + name string + openAIKey bool + wantNext string + }{ + {name: "OpenAI key present", openAIKey: true, wantNext: "msgvault setup providers --provider openai --model --credential-env OPENAI_API_KEY --retention-posture --training-posture --allow-sensitive= --yes"}, + {name: "no key", wantNext: "msgvault setup providers --provider --model --credential-env --retention-posture --training-posture --allow-sensitive= --yes"}, + } { + t.Run(test.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + fixture := newSetupProvidersFixture(t, setupProvidersMinimalConfig) + if test.openAIKey { + fixture.env[setupOpenAIKeyEnv] = setupProvidersTestKey + } + output, err := fixture.run(t, "status", "--json") + require.NoError(err, output) + var report laneReport + require.NoError(json.Unmarshal([]byte(output), &report)) + lane := findLane(t, report, lanePeopleInference) + assert.Equal([]string{test.wantNext, codexNext}, lane.Next) + human, err := fixture.run(t, "status") + require.NoError(err, human) + assert.Contains(human, "next: "+codexNext) + }) + } +} + +func TestSetupProvidersNoSelectionPlanShowsCompletePresetCommand(t *testing.T) { + const codexNext = "msgvault person provider enroll-codex --source conversation_text --source-since --retention-posture --training-posture --allow-sensitive=" + for _, test := range []struct { + name string + openAIKey bool + wantNext string + }{ + {name: "OpenAI key present", openAIKey: true, wantNext: "msgvault setup providers --provider openai --model --credential-env OPENAI_API_KEY --retention-posture --training-posture --allow-sensitive= --yes"}, + {name: "no key", wantNext: "msgvault setup providers --provider --model --credential-env --retention-posture --training-posture --allow-sensitive= --yes"}, + } { + t.Run(test.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + fixture := newSetupProvidersFixture(t, setupProvidersMinimalConfig) + if test.openAIKey { + fixture.env[setupOpenAIKeyEnv] = setupProvidersTestKey + } + output, err := fixture.run(t, "providers", "--dry-run", "--json") + require.NoError(err, output) + var result setupProvidersOutput + require.NoError(json.Unmarshal([]byte(output), &result)) + var inference setupLanePlan + for _, lane := range result.Plan { + if lane.Lane == lanePeopleInference { + inference = lane + break + } + } + require.Equal(lanePeopleInference, inference.Lane) + assert.Contains(inference.Reason, test.wantNext) + assert.Contains(inference.Reason, codexNext) + assert.False(result.Applied) + human, err := fixture.run(t, "providers", "--dry-run") + require.NoError(err, human) + assert.Contains(human, codexNext) + }) + } +} + +func TestSetupProvidersHelpListsCodexEnrollmentWithoutProviderFlag(t *testing.T) { + assert := assert.New(t) + + command := newSetupProvidersCommand(setupProvidersDeps{}) + help := command.Long + assert.Contains(help, "msgvault person provider enroll-codex") + assert.Contains(help, "requires a terminal") + assert.NotContains(help, "--provider codex") +} + +func TestSetupProvidersNamedPresetRequiresModel(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + fixture := newSetupProvidersFixture(t, setupProvidersMinimalConfig) + fixture.env["VENICE_KEY"] = setupProvidersTestKey + _, err := fixture.run(t, "providers", "--provider", "venice", "--credential-env", "VENICE_KEY", + "--retention-posture", "operator_asserted", "--training-posture", "operator_asserted", "--allow-sensitive", "--yes") + require.ErrorContains(err, "--model") + assert.NotContains(fixture.readConfig(t), "preset_id") + status, err := fixture.run(t, "status", "--json") + require.NoError(err, status) + var report laneReport + require.NoError(json.Unmarshal([]byte(status), &report)) + assert.Contains(findLane(t, report, lanePeopleInference).Reason, "--provider") +} + +func TestSetupProvidersRejectsKeySourceWithoutProviderChoice(t *testing.T) { + require := require.New(t) + + base := setupProvidersOptions{ + documentRetention: documentindex.RetentionStandard, + documentTraining: documentindex.TrainingDefaultOptOut, + retentionPosture: setupPostureDeclared, trainingPosture: setupPostureDeclared, + } + for _, change := range []func(*setupProvidersOptions){ + func(o *setupProvidersOptions) { o.model = "explicit/model" }, + func(o *setupProvidersOptions) { o.credentialEnv = "EXACT_KEY" }, + func(o *setupProvidersOptions) { o.apiKeyStdin = true }, + } { + options := base + change(&options) + require.ErrorContains(validateSetupProvidersOptions(options), "--provider") + } +} + +func TestSetupProvidersExplicitFalseSensitiveDecision(t *testing.T) { + for _, preset := range []string{"openai", "venice"} { + t.Run(preset, func(t *testing.T) { + require := require.New(t) + + assert := assert.New(t) + fixture := newSetupProvidersFixture(t, setupProvidersMinimalConfig) + args := []string{"providers", "--yes", "--allow-sensitive=false"} + if preset == "openai" { + fixture.env[setupOpenAIKeyEnv] = setupProvidersTestKey + args = append(args, explicitOpenAISetupFlags()...) + } else { + fixture.env["VENICE_KEY"] = setupProvidersTestKey + args = append(args, "--provider", "venice", "--model", "venice/model", + "--credential-env", "VENICE_KEY", "--retention-posture", "operator_asserted", + "--training-posture", "operator_asserted") + } + output, err := fixture.run(t, args...) + require.NoError(err, output) + provider := fixture.load(t).People.Sweep.Providers[preset] + assert.False(provider.AllowSensitive) + assert.Contains(output, "sensitive archive excerpts are excluded") + assert.NotContains(output, "--allow-sensitive authorizes") + }) + } } func TestSetupProvidersLocalOllamaFallback(t *testing.T) { @@ -1162,7 +1383,7 @@ func TestSetupProvidersDisclosureListsInferenceSources(t *testing.T) { fixture.env[setupOpenAIKeyEnv] = setupProvidersTestKey fixture.env["MISTRAL_API_KEY"] = setupProvidersTestKey - output, err := fixture.run(t, "providers", "--dry-run", "--allow-sensitive") + output, err := fixture.run(t, append([]string{"providers", "--dry-run", "--allow-sensitive"}, explicitOpenAISetupFlags()...)...) require.NoError(err, output) assert.Contains(output, "bounded evidence packets of conversation_text, meeting_text, document_text for tracked people") assert.Contains(output, "--allow-sensitive authorizes sending sensitive archive excerpts to OpenAI") @@ -1171,11 +1392,13 @@ func TestSetupProvidersDisclosureListsInferenceSources(t *testing.T) { func TestSetupProvidersDeclinedDocumentsUpdateDependentLanes(t *testing.T) { for _, local := range []bool{false, true} { t.Run(fmt.Sprint("local=", local), func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) fixture := newSetupProvidersFixture(t, setupProvidersMinimalConfig) fixture.env["MISTRAL_API_KEY"] = setupProvidersTestKey fixture.tty = true - profileName := setupInferenceProfile + profileName := "openai" if local { fixture.ollama = ollamaProbeResult{Reachable: true, Models: []string{"nomic-embed-text:latest", "gpt-oss-128k:latest"}} fixture.input = strings.NewReader("n\n") @@ -1185,8 +1408,12 @@ func TestSetupProvidersDeclinedDocumentsUpdateDependentLanes(t *testing.T) { fixture.input = strings.NewReader("n\ny\n") } - output, err := fixture.run(t, "providers", "--allow-sensitive") - require.NoError(t, err, output) + args := []string{"providers", "--allow-sensitive"} + if !local { + args = append(args, explicitOpenAISetupFlags()...) + } + output, err := fixture.run(t, args...) + require.NoError(err, output) loaded := fixture.load(t) assert.False(loaded.Attachments.Documents.Enabled) assert.False(loaded.Attachments.Documents.Index.Embeddings.Enabled) @@ -1235,7 +1462,7 @@ func TestSetupProvidersFailureRestoresConfig(t *testing.T) { return provider } command := newSetupProvidersCommand(deps) - command.SetArgs([]string{"--yes", "--allow-sensitive"}) + command.SetArgs(append([]string{"--yes", "--allow-sensitive"}, explicitOpenAISetupFlags()...)) command.SetOut(io.Discard) command.SetErr(io.Discard) @@ -1247,7 +1474,7 @@ func TestSetupProvidersFailureRestoresConfig(t *testing.T) { require.ErrorIs(err, os.ErrNotExist) } fixture.checker.err = nil - output, err := fixture.run(t, "providers", "--yes", "--allow-sensitive") + output, err := fixture.run(t, append([]string{"providers", "--yes", "--allow-sensitive"}, explicitOpenAISetupFlags()...)...) require.NoError(err, output) assert.True(fixture.load(t).People.Sweep.Enabled) }) @@ -1256,6 +1483,9 @@ func TestSetupProvidersFailureRestoresConfig(t *testing.T) { } func TestSetupProvidersRollbackPreservesConcurrentConfig(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + fixture := newSetupProvidersFixture(t, setupProvidersMinimalConfig) fixture.env[setupOpenAIKeyEnv] = setupProvidersTestKey deps := fixture.deps(t) @@ -1266,25 +1496,25 @@ func TestSetupProvidersRollbackPreservesConcurrentConfig(t *testing.T) { provider.newChecker = func(peoplesweep.Config, personProviderStore) (personProviderChecker, error) { return callbackPersonProviderChecker(func(context.Context) (peoplesweep.StructuredResponse, error) { before, err := config.ReadConfigFile(fixture.path) - require.NoError(t, err) + require.NoError(err) concurrent, err = config.EditConfigTables(fixture.path, before.ETag, []config.TableEdit{{ Path: []string{"activity"}, Values: map[string]any{"schedule": "0 * * * *"}, }}) - require.NoError(t, err) + require.NoError(err) return peoplesweep.StructuredResponse{}, failure }), nil } return provider } command := newSetupProvidersCommand(deps) - command.SetArgs([]string{"--yes", "--allow-sensitive"}) + command.SetArgs(append([]string{"--yes", "--allow-sensitive"}, explicitOpenAISetupFlags()...)) command.SetOut(io.Discard) command.SetErr(io.Discard) err := command.ExecuteContext(t.Context()) - require.ErrorIs(t, err, failure) - require.ErrorIs(t, err, config.ErrConfigConflict) - assert.Equal(t, string(concurrent.Content), fixture.readConfig(t)) + require.ErrorIs(err, failure) + require.ErrorIs(err, config.ErrConfigConflict) + assert.Equal(string(concurrent.Content), fixture.readConfig(t)) } func TestSetupProvidersWithoutProvidersReportsEveryLaneOff(t *testing.T) { @@ -1324,14 +1554,16 @@ func TestSetupProvidersDryRunWritesNothing(t *testing.T) { } func TestSetupProvidersRequiresConsentWithoutTerminal(t *testing.T) { + require := require.New(t) + assert := assert.New(t) fixture := newSetupProvidersFixture(t, setupProvidersMinimalConfig) fixture.env[setupVoyageKeyEnv] = setupProvidersTestKey before := fixture.readConfig(t) _, err := fixture.run(t, "providers") - require.ErrorContains(t, err, "--yes") - require.ErrorContains(t, err, gateVoyage) + require.ErrorContains(err, "--yes") + require.ErrorContains(err, gateVoyage) assert.Equal(before, fixture.readConfig(t)) } @@ -1375,7 +1607,7 @@ dimension = 3072 fixture.env[setupVoyageKeyEnv] = setupProvidersTestKey fixture.env[setupOpenAIKeyEnv] = setupProvidersTestKey - output, err := fixture.run(t, "providers", "--yes", "--allow-sensitive") + output, err := fixture.run(t, append([]string{"providers", "--yes", "--allow-sensitive"}, explicitOpenAISetupFlags()...)...) require.NoError(err, output) loaded := fixture.load(t) @@ -1389,6 +1621,9 @@ dimension = 3072 } func TestSetupProvidersRefusesConfiguredRemote(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + fixture := newSetupProvidersFixture(t, setupProvidersMinimalConfig) fixture.env[setupVoyageKeyEnv] = setupProvidersTestKey deps := fixture.deps(t) @@ -1400,8 +1635,8 @@ func TestSetupProvidersRefusesConfiguredRemote(t *testing.T) { root.SetErr(io.Discard) err := root.ExecuteContext(t.Context()) - require.ErrorContains(t, err, "remote daemon") - assert.Equal(t, strings.ReplaceAll(setupProvidersMinimalConfig, "{{DIR}}", filepath.ToSlash(fixture.dir)), fixture.readConfig(t)) + require.ErrorContains(err, "remote daemon") + assert.Equal(strings.ReplaceAll(setupProvidersMinimalConfig, "{{DIR}}", filepath.ToSlash(fixture.dir)), fixture.readConfig(t)) } func TestSetupStatusReportsPendingLanesForPresentKeys(t *testing.T) { @@ -1439,11 +1674,29 @@ func TestSetupStatusReportsPendingLanesForPresentKeys(t *testing.T) { func findLane(t *testing.T, report laneReport, lane string) laneStatus { t.Helper() + require := require.New(t) for _, item := range report.Lanes { if item.Lane == lane { return item } } - require.Failf(t, "lane missing", "lane %q not in report", lane) + require.Failf("lane missing", "lane %q not in report", lane) return laneStatus{} } + +func TestSetupProvidersRejectsExplicitProviderWhenSweepEnabled(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + fixture := newSetupProvidersFixture(t, setupProvidersMinimalConfig) + fixture.env[setupOpenAIKeyEnv] = setupProvidersTestKey + args := append([]string{"providers", "--yes", "--allow-sensitive"}, explicitOpenAISetupFlags()...) + _, err := fixture.run(t, args...) + require.NoError(err) + before, err := os.ReadFile(fixture.path) + require.NoError(err) + _, err = fixture.run(t, "providers", "--provider", "venice", "--model", "venice/model", "--credential-env", "VENICE_KEY", "--retention-posture", "operator_asserted", "--training-posture", "operator_asserted", "--allow-sensitive", "--yes") + require.ErrorContains(err, "people sweep is already enabled") + after, err := os.ReadFile(fixture.path) + require.NoError(err) + assert.Equal(before, after) +} diff --git a/cmd/msgvault/cmd/tui_people_codex_login.go b/cmd/msgvault/cmd/tui_people_codex_login.go new file mode 100644 index 000000000..becd4f9f0 --- /dev/null +++ b/cmd/msgvault/cmd/tui_people_codex_login.go @@ -0,0 +1,156 @@ +package cmd + +import ( + "context" + "errors" + "net/http" + + "go.kenn.io/msgvault/internal/tui" + "go.kenn.io/msgvault/pkg/client/generated" +) + +func (b *tuiDaemonSettingsBackend) StartCodexLogin(ctx context.Context, name string) (tui.CodexDeviceLogin, error) { + if name == "" { + return tui.CodexDeviceLogin{}, errors.New("codex profile name is required") + } + client, err := b.peopleInferenceClient() + if err != nil { + return tui.CodexDeviceLogin{}, err + } + response, err := client.StartSettingsPeopleCodexLoginWithResponse(ctx, + &generated.StartSettingsPeopleCodexLoginRequestOptions{ + Body: &generated.PeopleCodexLoginRequest{Name: name}, + }) + if err != nil || response == nil || response.StatusCode != http.StatusOK { + return tui.CodexDeviceLogin{}, peopleInferenceHTTPError("start Codex device login", response, err) + } + if response.JSON200 == nil || response.JSON200.SessionID == "" || + response.JSON200.VerificationURL == "" || response.JSON200.UserCode == "" || + response.JSON200.LocalDeadline.IsZero() { + return tui.CodexDeviceLogin{}, errors.New("start Codex device login: incomplete response") + } + return tui.CodexDeviceLogin{ + SessionID: response.JSON200.SessionID, + URL: response.JSON200.VerificationURL, Code: response.JSON200.UserCode, + Deadline: response.JSON200.LocalDeadline, + }, nil +} + +func (b *tuiDaemonSettingsBackend) PollCodexLogin(ctx context.Context, session string) (tui.CodexLoginPoll, error) { + client, err := b.peopleInferenceClient() + if err != nil { + return tui.CodexLoginPoll{}, err + } + response, err := client.GetSettingsPeopleCodexLoginWithResponse(ctx, + &generated.GetSettingsPeopleCodexLoginRequestOptions{ + PathParams: &generated.GetSettingsPeopleCodexLoginPath{ID: session}, + }) + if err != nil || response == nil || response.StatusCode != http.StatusOK { + return tui.CodexLoginPoll{Failed: response != nil && response.StatusCode == http.StatusNotFound}, + peopleInferenceHTTPError("poll Codex device login", response, err) + } + if response.JSON200 == nil { + return tui.CodexLoginPoll{}, errors.New("poll Codex device login: empty response") + } + switch response.JSON200.State { + case "complete": + return tui.CodexLoginPoll{Complete: true}, nil + case "pending": + return tui.CodexLoginPoll{}, nil + case "failed": + return tui.CodexLoginPoll{Failed: true}, errors.New("codex device login failed") + case "cancelled": + return tui.CodexLoginPoll{Failed: true}, errors.New("codex device login was cancelled") + default: + return tui.CodexLoginPoll{Failed: true}, errors.New("codex device login returned an unknown state") + } +} + +func (b *tuiDaemonSettingsBackend) CancelCodexLogin(ctx context.Context, session string) error { + client, err := b.peopleInferenceClient() + if err != nil { + return err + } + response, err := client.CancelSettingsPeopleCodexLoginWithResponse(ctx, + &generated.CancelSettingsPeopleCodexLoginRequestOptions{ + PathParams: &generated.CancelSettingsPeopleCodexLoginPath{ID: session}, + }) + if err != nil || response == nil || response.StatusCode != http.StatusOK { + return peopleInferenceHTTPError("cancel Codex device login", response, err) + } + if response.JSON200 == nil || response.JSON200.State != "cancelled" { + return errors.New("cancel Codex device login: cancellation was not confirmed") + } + return nil +} + +func (b *tuiDaemonSettingsBackend) ListCodexModels(ctx context.Context, session string) ([]tui.CodexModelChoice, error) { + client, err := b.peopleInferenceClient() + if err != nil { + return nil, err + } + response, err := client.GetSettingsPeopleCodexModelsWithResponse(ctx, + &generated.GetSettingsPeopleCodexModelsRequestOptions{ + PathParams: &generated.GetSettingsPeopleCodexModelsPath{ID: session}, + }) + if err != nil || response == nil || response.StatusCode != http.StatusOK { + return nil, peopleInferenceHTTPError("list Codex models", response, err) + } + if response.JSON200 == nil { + return nil, errors.New("list Codex models: empty response") + } + models := make([]tui.CodexModelChoice, 0, len(response.JSON200.Models)) + for _, model := range response.JSON200.Models { + if model.ID != "" { + models = append(models, tui.CodexModelChoice{ + ID: model.ID, DefaultReasoningEffort: model.DefaultReasoningEffort, + ReasoningEfforts: append([]string(nil), model.SupportedEfforts...), + }) + } + } + return models, nil +} + +func (b *tuiDaemonSettingsBackend) SaveCodexProfile( + ctx context.Context, session string, request tui.CodexProfileRequest, +) (string, error) { + client, err := b.peopleInferenceClient() + if err != nil { + return "", err + } + etag, err := b.peopleInferenceETag(ctx, client) + if err != nil { + return "", err + } + body := generated.PeopleCodexProfileRequest{ + Model: request.Model, ReasoningEffort: request.ReasoningEffort, + RetentionPosture: request.RetentionPosture, TrainingPosture: request.TrainingPosture, + AllowedSources: request.AllowedSources, SourceSince: request.SourceSince, + AllowSensitive: request.AllowSensitive, + } + if request.SourceUntil != "" { + body.SourceUntil = &request.SourceUntil + } + response, err := client.PutSettingsPeopleCodexProfileWithResponse(ctx, + &generated.PutSettingsPeopleCodexProfileRequestOptions{ + PathParams: &generated.PutSettingsPeopleCodexProfilePath{ID: session}, + Header: &generated.PutSettingsPeopleCodexProfileHeaders{IfMatch: etag}, + Body: &body, + }) + if response != nil && response.StatusCode == http.StatusPreconditionFailed { + return "", &tui.SettingsConflictError{Scope: tui.SettingsConflictConfig, + Err: peopleInferenceHTTPError("save Codex profile", response, err)} + } + if err != nil || response == nil || response.StatusCode != http.StatusOK { + return "", peopleInferenceHTTPError("save Codex profile", response, err) + } + if response.JSON200 == nil { + return "", errors.New("save Codex profile: empty response") + } + for _, profile := range response.JSON200.Profiles { + if profile.Name == request.Name && profile.Protocol == "codex_app_server" && profile.Model == request.Model { + return profile.Name, nil + } + } + return "", errors.New("save Codex profile: profile was not returned") +} diff --git a/cmd/msgvault/cmd/tui_people_inference.go b/cmd/msgvault/cmd/tui_people_inference.go new file mode 100644 index 000000000..b44587b4d --- /dev/null +++ b/cmd/msgvault/cmd/tui_people_inference.go @@ -0,0 +1,372 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "go.kenn.io/msgvault/internal/daemonclient" + "go.kenn.io/msgvault/internal/tui" + apiclient "go.kenn.io/msgvault/pkg/client" + "go.kenn.io/msgvault/pkg/client/generated" +) + +var _ tui.PeopleInferenceControlBackend = (*tuiDaemonSettingsBackend)(nil) +var _ tui.PeopleInferenceBackend = (*tuiDaemonSettingsBackend)(nil) + +// People inference enrollment runs through daemon routes; the TUI does not +// store Codex credentials or reproduce the daemon's enrollment rules. +func (b *tuiDaemonSettingsBackend) LoadPeopleInferenceStatus(ctx context.Context) (tui.PeopleInferenceStatus, error) { + client, err := b.peopleInferenceClient() + if err != nil { + return tui.PeopleInferenceStatus{}, err + } + response, err := client.GetSettingsPeopleInferenceWithResponse(ctx) + if err != nil || response == nil || response.StatusCode != http.StatusOK { + return tui.PeopleInferenceStatus{}, peopleInferenceHTTPError("load people inference status", response, err) + } + if response.JSON200 == nil { + return tui.PeopleInferenceStatus{}, errors.New("load people inference status: empty response") + } + return tuiPeopleInferenceStatus(response.JSON200), nil +} + +func (b *tuiDaemonSettingsBackend) CheckCodexProfile( + ctx context.Context, name string, +) (tui.PeopleInferenceDisclosure, error) { + client, err := b.peopleInferenceClient() + if err != nil { + return tui.PeopleInferenceDisclosure{}, err + } + etag, err := b.peopleInferenceETag(ctx, client) + if err != nil { + return tui.PeopleInferenceDisclosure{}, err + } + response, err := client.CheckSettingsPeopleInferenceProviderWithResponse(ctx, + &generated.CheckSettingsPeopleInferenceProviderRequestOptions{ + PathParams: &generated.CheckSettingsPeopleInferenceProviderPath{Name: name}, + Header: &generated.CheckSettingsPeopleInferenceProviderHeaders{IfMatch: etag}, + }) + if response != nil && response.StatusCode == http.StatusPreconditionFailed { + return tui.PeopleInferenceDisclosure{}, &tui.SettingsConflictError{Scope: tui.SettingsConflictConfig, + Err: peopleInferenceHTTPError("check people inference profile", response, err)} + } + if err != nil || response == nil || response.StatusCode != http.StatusOK { + return tui.PeopleInferenceDisclosure{}, peopleInferenceHTTPError("check people inference profile", response, err) + } + if response.JSON200 == nil || !response.JSON200.Ok || response.JSON200.Fingerprint == "" { + return tui.PeopleInferenceDisclosure{}, errors.New("check people inference profile: no successful check was returned") + } + profile, _, err := b.peopleInferenceProfileSnapshot(ctx, client, name) + if err != nil { + return tui.PeopleInferenceDisclosure{}, err + } + if profile.Fingerprint == nil || *profile.Fingerprint != response.JSON200.Fingerprint || profile.Model != response.JSON200.Model || !profile.Checked { + return tui.PeopleInferenceDisclosure{}, errors.New("people inference profile changed after check; reload settings") + } + return tui.PeopleInferenceDisclosure{ + Profile: name, Fingerprint: response.JSON200.Fingerprint, Text: peopleInferenceDisclosureText(profile), + }, nil +} + +func (b *tuiDaemonSettingsBackend) ConsentCodexProfile( + ctx context.Context, name, fingerprint string, +) error { + if fingerprint == "" { + return errors.New("check people inference profile before consenting") + } + client, err := b.peopleInferenceClient() + if err != nil { + return err + } + profile, etag, err := b.peopleInferenceProfileSnapshot(ctx, client, name) + if err != nil { + return err + } + if profile.Fingerprint == nil || *profile.Fingerprint != fingerprint { + return errors.New("people inference profile changed after check; reload settings") + } + if !profile.Checked { + return errors.New("run an exact synthetic check before consenting") + } + response, err := client.ConsentSettingsPeopleInferenceProviderWithResponse(ctx, + &generated.ConsentSettingsPeopleInferenceProviderRequestOptions{ + PathParams: &generated.ConsentSettingsPeopleInferenceProviderPath{Name: name}, + Header: &generated.ConsentSettingsPeopleInferenceProviderHeaders{IfMatch: etag}, + Body: &generated.PeopleInferenceConsentRequest{Fingerprint: fingerprint, Confirmed: true}, + }) + if response != nil && response.StatusCode == http.StatusPreconditionFailed { + return &tui.SettingsConflictError{Scope: tui.SettingsConflictConfig, + Err: peopleInferenceHTTPError("consent to people inference profile", response, err)} + } + if response != nil && response.StatusCode == http.StatusConflict && response.JSON409 != nil { + switch response.JSON409.ErrorData { + case "consent_disclosure_changed": + return errors.New("people inference disclosure changed; run the check again") + case "check_required": + return errors.New("run an exact synthetic check before consenting") + } + } + if err != nil || response == nil || response.StatusCode != http.StatusOK { + return peopleInferenceHTTPError("consent to people inference profile", response, err) + } + if response.JSON200 == nil { + return errors.New("consent to people inference profile: empty response") + } + for _, latest := range response.JSON200.Profiles { + if latest.Name == name && latest.Fingerprint != nil && *latest.Fingerprint == fingerprint && latest.ConsentActive { + return nil + } + } + return errors.New("consent to people inference profile: confirmation was not recorded") +} + +func (b *tuiDaemonSettingsBackend) RevokePeopleInferenceConsent( + ctx context.Context, name, fingerprint string, +) (tui.PeopleInferenceStatus, error) { + if fingerprint == "" { + return tui.PeopleInferenceStatus{}, errors.New("people inference profile fingerprint is required") + } + client, err := b.peopleInferenceClient() + if err != nil { + return tui.PeopleInferenceStatus{}, err + } + profile, etag, err := b.peopleInferenceProfileSnapshot(ctx, client, name) + if err != nil { + return tui.PeopleInferenceStatus{}, err + } + if profile.Fingerprint == nil || *profile.Fingerprint != fingerprint { + return tui.PeopleInferenceStatus{}, errors.New("people inference profile changed; reload settings") + } + response, err := client.RevokeSettingsPeopleInferenceProviderWithResponse(ctx, + &generated.RevokeSettingsPeopleInferenceProviderRequestOptions{ + PathParams: &generated.RevokeSettingsPeopleInferenceProviderPath{Name: name}, + Header: &generated.RevokeSettingsPeopleInferenceProviderHeaders{IfMatch: etag}, + }) + if response != nil && response.StatusCode == http.StatusPreconditionFailed { + return tui.PeopleInferenceStatus{}, &tui.SettingsConflictError{Scope: tui.SettingsConflictConfig, + Err: peopleInferenceHTTPError("revoke people inference consent", response, err)} + } + if err != nil || response == nil || response.StatusCode != http.StatusOK { + return tui.PeopleInferenceStatus{}, peopleInferenceHTTPError("revoke people inference consent", response, err) + } + if response.JSON200 == nil { + return tui.PeopleInferenceStatus{}, errors.New("revoke people inference consent: empty response") + } + for _, latest := range response.JSON200.Profiles { + if latest.Name == name && latest.Fingerprint != nil && *latest.Fingerprint == fingerprint && !latest.ConsentActive { + return tuiPeopleInferenceStatus(response.JSON200), nil + } + } + return tui.PeopleInferenceStatus{}, errors.New("revoke people inference consent: revocation was not recorded") +} + +func (b *tuiDaemonSettingsBackend) DisablePeopleInference( + ctx context.Context, fingerprint string, +) (tui.PeopleInferenceStatus, error) { + if fingerprint == "" { + return tui.PeopleInferenceStatus{}, errors.New("configured people inference fingerprint is required") + } + client, err := b.peopleInferenceClient() + if err != nil { + return tui.PeopleInferenceStatus{}, err + } + settings, etag, err := b.peopleInferenceSettingsSnapshot(ctx, client) + if err != nil { + return tui.PeopleInferenceStatus{}, err + } + if settings.ConfiguredFingerprint == nil || *settings.ConfiguredFingerprint != fingerprint { + return tui.PeopleInferenceStatus{}, errors.New("configured people inference profile changed; reload settings") + } + response, err := client.DisableSettingsPeopleInferenceWithResponse(ctx, + &generated.DisableSettingsPeopleInferenceRequestOptions{ + Header: &generated.DisableSettingsPeopleInferenceHeaders{IfMatch: etag}, + }) + if response != nil && response.StatusCode == http.StatusPreconditionFailed { + return tui.PeopleInferenceStatus{}, &tui.SettingsConflictError{Scope: tui.SettingsConflictConfig, + Err: peopleInferenceHTTPError("disable people inference", response, err)} + } + if err != nil || response == nil || response.StatusCode != http.StatusOK { + return tui.PeopleInferenceStatus{}, peopleInferenceHTTPError("disable people inference", response, err) + } + if response.JSON200 == nil || response.JSON200.ConfiguredEnabled { + return tui.PeopleInferenceStatus{}, errors.New("disable people inference: disabled status was not returned") + } + return tuiPeopleInferenceStatus(response.JSON200), nil +} + +func (b *tuiDaemonSettingsBackend) RemovePeopleInferenceProfile( + ctx context.Context, name, fingerprint string, +) (tui.PeopleInferenceStatus, error) { + if fingerprint == "" { + return tui.PeopleInferenceStatus{}, errors.New("people inference profile fingerprint is required") + } + client, err := b.peopleInferenceClient() + if err != nil { + return tui.PeopleInferenceStatus{}, err + } + profile, etag, err := b.peopleInferenceProfileSnapshot(ctx, client, name) + if err != nil { + return tui.PeopleInferenceStatus{}, err + } + if profile.Fingerprint == nil || *profile.Fingerprint != fingerprint { + return tui.PeopleInferenceStatus{}, errors.New("people inference profile changed; reload settings") + } + response, err := client.DeleteSettingsPeopleInferenceProviderWithResponse(ctx, + &generated.DeleteSettingsPeopleInferenceProviderRequestOptions{ + PathParams: &generated.DeleteSettingsPeopleInferenceProviderPath{Name: name}, + Header: &generated.DeleteSettingsPeopleInferenceProviderHeaders{IfMatch: etag}, + }) + if response != nil && response.StatusCode == http.StatusPreconditionFailed { + return tui.PeopleInferenceStatus{}, &tui.SettingsConflictError{Scope: tui.SettingsConflictConfig, + Err: peopleInferenceHTTPError("remove people inference profile", response, err)} + } + if response != nil && response.StatusCode == http.StatusConflict && response.JSON409 != nil && + response.JSON409.ErrorData == "provider_in_use" { + return tui.PeopleInferenceStatus{}, errors.New("disable people inference or configure another profile before removing") + } + if err != nil || response == nil || response.StatusCode != http.StatusOK { + return tui.PeopleInferenceStatus{}, peopleInferenceHTTPError("remove people inference profile", response, err) + } + if response.JSON200 == nil { + return tui.PeopleInferenceStatus{}, errors.New("remove people inference profile: empty response") + } + for _, latest := range response.JSON200.Profiles { + if latest.Name == name { + return tui.PeopleInferenceStatus{}, errors.New("remove people inference profile: profile is still present") + } + } + return tuiPeopleInferenceStatus(response.JSON200), nil +} + +func (b *tuiDaemonSettingsBackend) SelectCodexProfile(ctx context.Context, name string) error { + client, err := b.peopleInferenceClient() + if err != nil { + return err + } + etag, err := b.peopleInferenceETag(ctx, client) + if err != nil { + return err + } + response, err := client.SelectSettingsPeopleInferenceWithResponse(ctx, + &generated.SelectSettingsPeopleInferenceRequestOptions{ + Header: &generated.SelectSettingsPeopleInferenceHeaders{IfMatch: etag}, + Body: &generated.PeopleInferenceSelectionRequest{Name: name}, + }) + if response != nil && response.StatusCode == http.StatusPreconditionFailed { + return &tui.SettingsConflictError{Scope: tui.SettingsConflictConfig, + Err: peopleInferenceHTTPError("select people inference profile", response, err)} + } + if response != nil && response.StatusCode == http.StatusConflict && response.JSON409 != nil { + switch response.JSON409.ErrorData { + case "check_required": + return errors.New("run an exact synthetic check before selecting") + case "consent_required": + return errors.New("grant exact people inference consent before selecting") + } + } + if err != nil || response == nil || response.StatusCode != http.StatusOK { + return peopleInferenceHTTPError("select people inference profile", response, err) + } + return nil +} + +func (b *tuiDaemonSettingsBackend) peopleInferenceClient() (*apiclient.Client, error) { + if b == nil || b.client == nil { + return nil, errors.New("daemon settings client unavailable") + } + return b.client.GeneratedClient() +} + +func (b *tuiDaemonSettingsBackend) peopleInferenceETag(ctx context.Context, client *apiclient.Client) (string, error) { + response, err := client.GetSettingsPeopleInferenceWithResponse(ctx) + if err != nil || response == nil || response.StatusCode != http.StatusOK { + return "", peopleInferenceHTTPError("load people inference config revision", response, err) + } + if response.Headers200 == nil || response.Headers200.ETag == "" { + return "", errors.New("people inference status returned no config ETag") + } + return response.Headers200.ETag, nil +} + +func (b *tuiDaemonSettingsBackend) peopleInferenceProfileSnapshot( + ctx context.Context, client *apiclient.Client, name string, +) (generated.PeopleInferenceProfileSetting, string, error) { + settings, etag, err := b.peopleInferenceSettingsSnapshot(ctx, client) + if err != nil { + return generated.PeopleInferenceProfileSetting{}, "", err + } + for _, profile := range settings.Profiles { + if profile.Name == name { + return profile, etag, nil + } + } + return generated.PeopleInferenceProfileSetting{}, "", errors.New("people inference profile was not found") +} + +func (b *tuiDaemonSettingsBackend) peopleInferenceSettingsSnapshot( + ctx context.Context, client *apiclient.Client, +) (*generated.PeopleInferenceSettingsResponse, string, error) { + response, err := client.GetSettingsPeopleInferenceWithResponse(ctx) + if err != nil || response == nil || response.StatusCode != http.StatusOK { + return nil, "", peopleInferenceHTTPError("load people inference status", response, err) + } + if response.JSON200 == nil || response.Headers200 == nil || response.Headers200.ETag == "" { + return nil, "", errors.New("people inference status returned no body or config ETag") + } + return response.JSON200, response.Headers200.ETag, nil +} + +func peopleInferenceDisclosureText(profile generated.PeopleInferenceProfileSetting) string { + sourceDates := profile.SourceSince + if profile.SourceUntil != nil && *profile.SourceUntil != "" { + sourceDates += " to " + *profile.SourceUntil + } + sensitive := "no" + if profile.AllowSensitive { + sensitive = "yes" + } + lines := []string{ + "Provider: " + profile.Name, + "Fingerprint: " + *profile.Fingerprint, + "Protocol: " + profile.Protocol, + "Model: " + profile.Model, + } + if profile.Endpoint != nil && *profile.Endpoint != "" { + lines = append(lines, "Endpoint: "+*profile.Endpoint) + } + lines = append(lines, + "Sources: "+strings.Join(profile.AllowedSources, ", "), + "Source dates: "+sourceDates, + "Sensitive content: "+sensitive, + "Retention: "+profile.RetentionPosture, + "Training: "+profile.TrainingPosture, + ) + return strings.Join(lines, "\n") +} + +func peopleInferenceHTTPError(operation string, response any, err error) error { + return fmt.Errorf("%s: %w", operation, daemonclient.APIResponseError(response, err)) +} + +func tuiPeopleInferenceStatus(response *generated.PeopleInferenceSettingsResponse) tui.PeopleInferenceStatus { + status := tui.PeopleInferenceStatus{ + ConfiguredEnabled: response.ConfiguredEnabled, RunningEnabled: response.RunningEnabled, + PendingRestart: response.PendingRestart, + } + if response.ConfiguredName != nil { + status.Configured = *response.ConfiguredName + } + if response.ConfiguredFingerprint != nil { + status.ConfiguredFingerprint = *response.ConfiguredFingerprint + } + if response.RunningName != nil { + status.Running = *response.RunningName + } + if response.RunningFingerprint != nil { + status.RunningFingerprint = *response.RunningFingerprint + } + return status +} diff --git a/cmd/msgvault/cmd/tui_people_inference_test.go b/cmd/msgvault/cmd/tui_people_inference_test.go new file mode 100644 index 000000000..093a9b4bf --- /dev/null +++ b/cmd/msgvault/cmd/tui_people_inference_test.go @@ -0,0 +1,658 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/tui" +) + +func TestTUICodexAdapterUsesGeneratedLoginAndProfileRoutes(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + var calls []string + polls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.Method + " " + r.URL.Path { + case "POST /api/v1/settings/people-inference/codex/login": + var body map[string]any + if !assert.NoError(json.NewDecoder(r.Body).Decode(&body)) { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + assert.Equal(map[string]any{"name": "codex-custom"}, body) + _, _ = io.WriteString(w, `{"session_id":"session-1","verification_url":"https://example.test/device","user_code":"ABCD-EFGH","local_deadline":"2026-09-23T12:05:00Z"}`) + case "GET /api/v1/settings/people-inference/codex/login/session-1": + polls++ + state := "pending" + if polls == 2 { + state = "complete" + } + _, _ = io.WriteString(w, `{"state":"`+state+`"}`) + case "GET /api/v1/settings/people-inference/codex/login/session-1/models": + _, _ = io.WriteString(w, `{"models":[{"id":"codex-model-a","display_name":"Model A","default_reasoning_effort":"medium","supported_efforts":["low","medium"]}]}`) + case "GET /api/v1/settings/people-inference": + w.Header().Set("ETag", `"config-1"`) + _, _ = io.WriteString(w, `{"profiles":[]}`) + case "PUT /api/v1/settings/people-inference/codex/login/session-1/profile": + assert.Equal(`"config-1"`, r.Header.Get("If-Match")) + var body map[string]any + if !assert.NoError(json.NewDecoder(r.Body).Decode(&body)) { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + assert.Equal(map[string]any{ + "model": "codex-model-a", "reasoning_effort": "low", "retention_posture": "operator assertion: no retention", + "training_posture": "operator assertion: no training", "allowed_sources": []any{"conversation_text"}, + "source_since": "2025-01-01", "source_until": "2025-12-31", "allow_sensitive": false, + }, body) + _, _ = io.WriteString(w, `{"profiles":[{"name":"codex-custom","protocol":"codex_app_server","model":"codex-model-a","output_mode":"native_json_schema","credential_source":"none","allowed_sources":["conversation_text"],"source_since":"2025-01-01","retention_posture":"operator assertion: no retention","training_posture":"operator assertion: no training"}]}`) + case "DELETE /api/v1/settings/people-inference/codex/login/session-1": + _, _ = io.WriteString(w, `{"state":"cancelled"}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + + login, err := backend.StartCodexLogin(context.Background(), "codex-custom") + require.NoError(err) + assert.Equal("session-1", login.SessionID) + assert.Equal("https://example.test/device", login.URL) + assert.Equal("ABCD-EFGH", login.Code) + assert.Equal(time.Date(2026, 9, 23, 12, 5, 0, 0, time.UTC), login.Deadline) + poll, err := backend.PollCodexLogin(context.Background(), login.SessionID) + require.NoError(err) + assert.False(poll.Complete) + poll, err = backend.PollCodexLogin(context.Background(), login.SessionID) + require.NoError(err) + assert.True(poll.Complete) + models, err := backend.ListCodexModels(context.Background(), login.SessionID) + require.NoError(err) + assert.Equal([]tui.CodexModelChoice{{ID: "codex-model-a", DefaultReasoningEffort: "medium", ReasoningEfforts: []string{"low", "medium"}}}, models) + profile, err := backend.SaveCodexProfile(context.Background(), login.SessionID, tui.CodexProfileRequest{ + Name: "codex-custom", Model: models[0].ID, ReasoningEffort: "low", + RetentionPosture: "operator assertion: no retention", TrainingPosture: "operator assertion: no training", + AllowedSources: []string{"conversation_text"}, SourceSince: "2025-01-01", SourceUntil: "2025-12-31", + AllowSensitive: false, + }) + require.NoError(err) + assert.Equal("codex-custom", profile) + require.NoError(backend.CancelCodexLogin(context.Background(), login.SessionID)) + assert.Equal([]string{ + "POST /api/v1/settings/people-inference/codex/login", + "GET /api/v1/settings/people-inference/codex/login/session-1", + "GET /api/v1/settings/people-inference/codex/login/session-1", + "GET /api/v1/settings/people-inference/codex/login/session-1/models", + "GET /api/v1/settings/people-inference", + "PUT /api/v1/settings/people-inference/codex/login/session-1/profile", + "DELETE /api/v1/settings/people-inference/codex/login/session-1", + }, calls) +} + +func TestTUICodexProfileSaveReportsConfigConflict(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method + " " + r.URL.Path { + case "GET /api/v1/settings/people-inference": + w.Header().Set("ETag", `"config-1"`) + _, _ = io.WriteString(w, `{"profiles":[]}`) + case "PUT /api/v1/settings/people-inference/codex/login/session-1/profile": + assert.Equal(`"config-1"`, r.Header.Get("If-Match")) + w.WriteHeader(http.StatusPreconditionFailed) + _, _ = io.WriteString(w, `{"error":"settings_conflict"}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + _, err := backend.SaveCodexProfile(context.Background(), "session-1", tui.CodexProfileRequest{ + Name: "codex-custom", Model: "codex-model-a", ReasoningEffort: "low", + RetentionPosture: "operator assertion", TrainingPosture: "operator assertion", + AllowedSources: []string{"conversation_text"}, SourceSince: "2025-01-01", + }) + require.Error(err) + var conflict *tui.SettingsConflictError + require.ErrorAs(err, &conflict) + assert.Equal(tui.SettingsConflictConfig, conflict.Scope) +} + +func TestTUIPeopleInferenceBackendUsesGeneratedStatusRoute(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + var calls []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.Method + " " + r.URL.Path { + case "GET /api/v1/settings/people-inference": + w.Header().Set("ETag", `"config-1"`) + _, _ = io.WriteString(w, `{"profiles":[],"configured_name":"old","configured_fingerprint":"fp-configured","running_name":"old","running_fingerprint":"fp-running","configured_enabled":true,"running_enabled":true,"pending_restart":false}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + + status, err := backend.LoadPeopleInferenceStatus(context.Background()) + require.NoError(err) + assert.Equal("old", status.Configured) + assert.Equal("old", status.Running) + assert.Equal("fp-configured", status.ConfiguredFingerprint) + assert.Equal("fp-running", status.RunningFingerprint) + assert.True(status.ConfiguredEnabled) + assert.True(status.RunningEnabled) + assert.False(status.PendingRestart) + + assert.Equal([]string{"GET /api/v1/settings/people-inference"}, calls) +} + +func TestTUIPeopleInferenceBackendCheckUsesExactProfile(t *testing.T) { + require := require.New(t) + + assert := assert.New(t) + var calls []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.Method + " " + r.URL.Path { + case "GET /api/v1/settings/people-inference": + w.Header().Set("ETag", `"config-1"`) + _, _ = io.WriteString(w, `{"profiles":[{"name":"codex-profile","fingerprint":"fp-1","protocol":"codex_app_server","model":"codex-model","endpoint":"https://api.example.test/v1","allowed_sources":["conversation_text"],"source_since":"2025-01-01","source_until":"2025-12-31","allow_sensitive":false,"retention_posture":"operator-confirmed","training_posture":"operator-confirmed","checked":true}]}`) + case "POST /api/v1/settings/people-inference/providers/codex-profile/check": + assert.Equal(`"config-1"`, r.Header.Get("If-Match")) + _, _ = io.WriteString(w, `{"ok":true,"fingerprint":"fp-1","model":"codex-model","usage":{}}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + + disclosure, err := backend.CheckCodexProfile(context.Background(), "codex-profile") + require.NoError(err) + assert.Equal("codex-profile", disclosure.Profile) + assert.Equal("fp-1", disclosure.Fingerprint) + for _, field := range []string{"fp-1", "codex_app_server", "codex-model", "https://api.example.test/v1", "conversation_text", "2025-01-01", "2025-12-31", "operator-confirmed", "Sensitive content: no"} { + assert.Contains(disclosure.Text, field) + } + assert.Equal([]string{ + "GET /api/v1/settings/people-inference", + "POST /api/v1/settings/people-inference/providers/codex-profile/check", + "GET /api/v1/settings/people-inference", + }, calls) +} + +func TestTUIPeopleInferenceBackendCheckRejectsChangedFingerprint(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + w.Header().Set("ETag", `"config-1"`) + _, _ = io.WriteString(w, `{"profiles":[{"name":"codex-profile","fingerprint":"fp-new","model":"codex-model"}]}`) + return + } + _, _ = io.WriteString(w, `{"ok":true,"fingerprint":"fp-old","model":"codex-model","usage":{}}`) + })) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + + _, err := backend.CheckCodexProfile(context.Background(), "codex-profile") + require.Error(err) + assert.Contains(err.Error(), "changed") +} + +func TestTUIPeopleInferenceBackendConsentSendsExactConfirmation(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + var calls []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.Method + " " + r.URL.Path { + case "GET /api/v1/settings/people-inference": + w.Header().Set("ETag", `"config-2"`) + _, _ = io.WriteString(w, `{"profiles":[{"name":"codex-profile","fingerprint":"fp-1","checked":true}]}`) + case "POST /api/v1/settings/people-inference/providers/codex-profile/consent": + assert.Equal(`"config-2"`, r.Header.Get("If-Match")) + var body map[string]any + if !assert.NoError(json.NewDecoder(r.Body).Decode(&body)) { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + assert.Equal(map[string]any{"confirmed": true, "fingerprint": "fp-1"}, body) + _, _ = io.WriteString(w, `{"profiles":[{"name":"codex-profile","fingerprint":"fp-1","consent_active":true}]}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + + require.NoError(backend.ConsentCodexProfile(context.Background(), "codex-profile", "fp-1")) + assert.Equal([]string{ + "GET /api/v1/settings/people-inference", + "POST /api/v1/settings/people-inference/providers/codex-profile/consent", + }, calls) +} + +func TestTUIPeopleInferenceBackendConsentRejectsStaleFingerprint(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + var calls []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("ETag", `"config-2"`) + _, _ = io.WriteString(w, `{"profiles":[{"name":"codex-profile","fingerprint":"fp-new","checked":true}]}`) + })) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + + err := backend.ConsentCodexProfile(context.Background(), "codex-profile", "fp-old") + require.Error(err) + assert.Contains(err.Error(), "changed") + assert.Equal([]string{"GET /api/v1/settings/people-inference"}, calls) +} + +func TestTUIPeopleInferenceBackendRevokeUsesExactFingerprint(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + var calls []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.Method + " " + r.URL.Path { + case "GET /api/v1/settings/people-inference": + w.Header().Set("ETag", `"config-3"`) + _, _ = io.WriteString(w, `{"profiles":[{"name":"codex-profile","fingerprint":"fp-1","consent_active":true}],"configured_name":"codex-profile","configured_fingerprint":"fp-1"}`) + case "POST /api/v1/settings/people-inference/providers/codex-profile/revoke": + assert.Equal(`"config-3"`, r.Header.Get("If-Match")) + _, _ = io.WriteString(w, `{"profiles":[{"name":"codex-profile","fingerprint":"fp-1","consent_active":false}],"configured_name":"codex-profile","configured_fingerprint":"fp-1"}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + + status, err := backend.RevokePeopleInferenceConsent(context.Background(), "codex-profile", "fp-1") + require.NoError(err) + assert.Equal("fp-1", status.ConfiguredFingerprint) + assert.Equal([]string{ + "GET /api/v1/settings/people-inference", + "POST /api/v1/settings/people-inference/providers/codex-profile/revoke", + }, calls) +} + +func TestTUIPeopleInferenceBackendDisableUsesExactFingerprint(t *testing.T) { + require := require.New(t) + + assert := assert.New(t) + var calls []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.Method + " " + r.URL.Path { + case "GET /api/v1/settings/people-inference": + w.Header().Set("ETag", `"config-3"`) + _, _ = io.WriteString(w, `{"profiles":[],"configured_name":"codex-profile","configured_fingerprint":"fp-1","configured_enabled":true,"running_enabled":true}`) + case "POST /api/v1/settings/people-inference/disable": + assert.Equal(`"config-3"`, r.Header.Get("If-Match")) + _, _ = io.WriteString(w, `{"profiles":[],"configured_name":"codex-profile","configured_fingerprint":"fp-1","configured_enabled":false,"running_enabled":true,"pending_restart":true}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + + status, err := backend.DisablePeopleInference(context.Background(), "fp-1") + require.NoError(err) + assert.False(status.ConfiguredEnabled) + assert.True(status.PendingRestart) + assert.Equal([]string{ + "GET /api/v1/settings/people-inference", + "POST /api/v1/settings/people-inference/disable", + }, calls) +} + +func TestTUIPeopleInferenceBackendRemoveUsesExactFingerprint(t *testing.T) { + require := require.New(t) + + assert := assert.New(t) + var calls []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.Method + " " + r.URL.Path { + case "GET /api/v1/settings/people-inference": + w.Header().Set("ETag", `"config-4"`) + _, _ = io.WriteString(w, `{"profiles":[{"name":"router","fingerprint":"fp-router"},{"name":"backup","fingerprint":"fp-backup"}],"configured_name":"router","configured_fingerprint":"fp-router","configured_enabled":false}`) + case "DELETE /api/v1/settings/people-inference/providers/router": + assert.Equal(`"config-4"`, r.Header.Get("If-Match")) + _, _ = io.WriteString(w, `{"profiles":[{"name":"backup","fingerprint":"fp-backup"}],"configured_name":"backup","configured_fingerprint":"fp-backup","pending_restart":true}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + + status, err := backend.RemovePeopleInferenceProfile(context.Background(), "router", "fp-router") + require.NoError(err) + assert.Equal("backup", status.Configured) + assert.True(status.PendingRestart) + assert.Equal([]string{ + "GET /api/v1/settings/people-inference", + "DELETE /api/v1/settings/people-inference/providers/router", + }, calls) +} + +func TestTUIPeopleInferenceBackendRemoveRejectsChangedFingerprint(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + var calls []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("ETag", `"config-new"`) + _, _ = io.WriteString(w, `{"profiles":[{"name":"router","fingerprint":"fp-new"}],"configured_enabled":false}`) + })) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + + _, err := backend.RemovePeopleInferenceProfile(context.Background(), "router", "fp-old") + require.Error(err) + assert.Contains(err.Error(), "changed") + assert.Equal([]string{"GET /api/v1/settings/people-inference"}, calls) +} + +func TestTUIPeopleInferenceBackendRemoveReportsConflict(t *testing.T) { + for _, status := range []int{http.StatusConflict, http.StatusPreconditionFailed} { + t.Run(http.StatusText(status), func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + w.Header().Set("ETag", `"config-4"`) + _, _ = io.WriteString(w, `{"profiles":[{"name":"router","fingerprint":"fp-router"}],"configured_enabled":false}`) + return + } + assert.Equal(`"config-4"`, r.Header.Get("If-Match")) + w.WriteHeader(status) + _, _ = io.WriteString(w, `{"error":"provider_in_use"}`) + })) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + + _, err := backend.RemovePeopleInferenceProfile(context.Background(), "router", "fp-router") + require.Error(err) + if status == http.StatusPreconditionFailed { + var conflict *tui.SettingsConflictError + require.ErrorAs(err, &conflict) + assert.Equal(tui.SettingsConflictConfig, conflict.Scope) + } else { + assert.Contains(err.Error(), "another profile") + } + }) + } +} + +func TestTUIPeopleInferenceBackendRevokeAndDisableRejectStaleFingerprint(t *testing.T) { + for _, operation := range []string{"revoke", "disable"} { + t.Run(operation, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + var calls []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("ETag", `"config-new"`) + _, _ = io.WriteString(w, `{"profiles":[{"name":"codex-profile","fingerprint":"fp-new"}],"configured_fingerprint":"fp-new","configured_enabled":true}`) + })) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + var err error + if operation == "revoke" { + _, err = backend.RevokePeopleInferenceConsent(context.Background(), "codex-profile", "fp-old") + } else { + _, err = backend.DisablePeopleInference(context.Background(), "fp-old") + } + require.Error(err) + assert.Contains(err.Error(), "changed") + assert.Equal([]string{"GET /api/v1/settings/people-inference"}, calls) + }) + } +} + +func TestTUIPeopleInferenceBackendRevokeAndDisablePreserveConfigConflict(t *testing.T) { + for _, operation := range []string{"revoke", "disable"} { + t.Run(operation, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + w.Header().Set("ETag", `"config-old"`) + _, _ = io.WriteString(w, `{"profiles":[{"name":"codex-profile","fingerprint":"fp-1"}],"configured_fingerprint":"fp-1","configured_enabled":true}`) + return + } + assert.Equal(`"config-old"`, r.Header.Get("If-Match")) + w.WriteHeader(http.StatusPreconditionFailed) + _, _ = io.WriteString(w, `{"error":"settings_conflict"}`) + })) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + var err error + if operation == "revoke" { + _, err = backend.RevokePeopleInferenceConsent(context.Background(), "codex-profile", "fp-1") + } else { + _, err = backend.DisablePeopleInference(context.Background(), "fp-1") + } + var conflict *tui.SettingsConflictError + require.ErrorAs(err, &conflict) + assert.Equal(tui.SettingsConflictConfig, conflict.Scope) + }) + } +} + +func TestTUIPeopleInferenceBackendCheckAndConsentConflicts(t *testing.T) { + for _, test := range []struct { + name string + operation string + status int + body string + want string + conflict bool + }{ + {name: "check config conflict", operation: "check", status: 412, body: `{"error":"settings_conflict"}`, conflict: true}, + {name: "consent config conflict", operation: "consent", status: 412, body: `{"error":"settings_conflict"}`, conflict: true}, + {name: "consent changed disclosure", operation: "consent", status: 409, body: `{"error":"consent_disclosure_changed"}`, want: "disclosure changed"}, + {name: "consent check missing", operation: "consent", status: 409, body: `{"error":"check_required"}`, want: "synthetic check"}, + {name: "check daemon error", operation: "check", status: 500, body: `{"error":"settings_read_failed","message":"people inference configuration is unavailable"}`, want: "500"}, + {name: "consent daemon error", operation: "consent", status: 500, body: `{"error":"settings_read_failed","message":"people inference configuration is unavailable"}`, want: "500"}, + } { + t.Run(test.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + w.Header().Set("ETag", `"config-1"`) + _, _ = io.WriteString(w, `{"profiles":[{"name":"codex-profile","fingerprint":"fp-1","checked":true}]}`) + return + } + assert.Equal(`"config-1"`, r.Header.Get("If-Match")) + w.WriteHeader(test.status) + _, _ = io.WriteString(w, test.body) + })) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + var err error + if test.operation == "check" { + _, err = backend.CheckCodexProfile(context.Background(), "codex-profile") + } else { + err = backend.ConsentCodexProfile(context.Background(), "codex-profile", "fp-1") + } + require.Error(err) + if test.conflict { + var conflict *tui.SettingsConflictError + require.ErrorAs(err, &conflict) + assert.Equal(tui.SettingsConflictConfig, conflict.Scope) + } else { + assert.Contains(err.Error(), test.want) + } + }) + } +} + +func TestTUIPeopleInferenceBackendSelectPreservesConfigConflict(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method + " " + r.URL.Path { + case "GET /api/v1/settings/people-inference": + w.Header().Set("ETag", `"config-old"`) + _, _ = io.WriteString(w, `{"profiles":[],"pending_restart":false}`) + case "POST /api/v1/settings/people-inference/select": + assert.Equal(`"config-old"`, r.Header.Get("If-Match")) + var body map[string]any + if !assert.NoError(json.NewDecoder(r.Body).Decode(&body)) { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + assert.Equal("codex-profile", body["name"]) + w.WriteHeader(http.StatusPreconditionFailed) + _, _ = io.WriteString(w, `{}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + + err := backend.SelectCodexProfile(context.Background(), "codex-profile") + var conflict *tui.SettingsConflictError + require.ErrorAs(err, &conflict) + assert.Equal(tui.SettingsConflictConfig, conflict.Scope) +} + +func TestTUIPeopleInferenceBackendSelectReportsDaemonGate(t *testing.T) { + for _, test := range []struct { + code string + want string + }{ + {code: "check_required", want: "run an exact synthetic check before selecting"}, + {code: "consent_required", want: "grant exact people inference consent before selecting"}, + } { + t.Run(test.code, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + w.Header().Set("ETag", `"config-1"`) + _, _ = io.WriteString(w, `{"profiles":[]}`) + return + } + w.WriteHeader(http.StatusConflict) + _, _ = io.WriteString(w, `{"error":"`+test.code+`"}`) + })) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + + err := backend.SelectCodexProfile(context.Background(), "codex-profile") + require.Error(err) + assert.Equal(test.want, err.Error()) + }) + } +} + +func TestTUIPeopleInferenceBackendRejectsServerFailure(t *testing.T) { + for _, operation := range []string{"status", "select"} { + t.Run(operation, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if operation != "status" && r.Method == http.MethodGet { + w.Header().Set("ETag", `"config-1"`) + _, _ = io.WriteString(w, `{"profiles":[]}`) + return + } + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `{"error":"settings_read_failed","message":"people inference configuration is unavailable"}`) + })) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + var err error + switch operation { + case "status": + _, err = backend.LoadPeopleInferenceStatus(context.Background()) + case "select": + err = backend.SelectCodexProfile(context.Background(), "codex-profile") + } + require.Error(err) + assert.Contains(err.Error(), "500") + assert.Contains(err.Error(), "people inference configuration is unavailable") + }) + } +} + +func TestTUICodexAdapterReportsLoginFailure(t *testing.T) { + for _, state := range []string{"failed", "cancelled"} { + t.Run(state, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"state":"`+state+`"}`) + })) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + poll, err := backend.PollCodexLogin(t.Context(), "session-1") + require.ErrorContains(t, err, state) + assert.True(t, poll.Failed) + }) + } +} + +func TestTUICodexAdapterStopsPollingMissingSession(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"error":"codex_login_not_found","message":"Codex device login was not found"}`) + })) + t.Cleanup(server.Close) + backend := newTUISettingsBackend(newTUISettingsDaemonClient(t, server)) + poll, err := backend.PollCodexLogin(t.Context(), "session-1") + require.ErrorContains(t, err, "Codex device login was not found") + assert.True(t, poll.Failed) +} diff --git a/docs/cli-reference.md b/docs/cli-reference.md index e75d368d6..dd90e8e29 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -2180,6 +2180,64 @@ JSON contains a `people` array and optional `next_cursor`. Each person retains t --- +## person provider add + +Create and synthetically check a named people inference profile. Select a +built-in preset to bind its protocol, endpoint, and authentication scheme: + +```bash +msgvault person provider add primary --provider openrouter --model \ + --credential-env OPENROUTER_API_KEY --retention-posture \ + --training-posture --source conversation_text \ + --source-since 2026-01-01 --allow-sensitive=true --yes +msgvault person provider consent primary --yes +msgvault person provider use primary +``` + +`--provider` accepts `openai`, `openrouter`, or `venice` and requires an explicit +model and privacy policy. It cannot be combined with `--custom` or +`--accept-catalog-prices`; conflicting protocol, endpoint, or auth overrides +are rejected. `--credential-env` reads only the named host variable. Alternatively, +`--api-key-stdin` reads a key from standard input, or an interactive terminal +prompts for it. A successful synthetic check does not grant consent or select +the profile. See [profile automation](usage/people-automation.md) for custom +protocol profiles and policy fields. + +## person provider enroll-codex + +Create, check, consent to, and select a new Codex profile through the daemon. +**Codex enrollment is unavailable in this release:** no Codex build is approved. +The daemon returns HTTP 503 before changing credentials or consent. This command +requires a terminal and never starts a noninteractive device login. + +```bash +msgvault person provider enroll-codex \ + --retention-posture --training-posture \ + --source conversation_text --source-since 2026-01-01 --allow-sensitive=true +``` + +The gated flow prints a verification URL, user code, and local deadline, waits +for sign-in, then prompts for an available model and reasoning effort. Existing +profile names are rejected. After saving, a synthetic check and separate +consent are required before selection. Declining consent leaves the saved +profile unselected. Restart the daemon when the reported running profile differs. + +| Flag | Default | Description | +|---|---|---| +| `--model` | prompt | Codex model ID available to the signed-in account | +| `--reasoning-effort` | prompt | Supported effort for the chosen model | +| `--retention-posture`, `--training-posture` | required | Explicit operator assertions | +| `--source` | required | Repeatable: `conversation_text`, `meeting_text`, or `document_text` | +| `--source-since` | required | Earliest disclosed date, `YYYY-MM-DD` | +| `--source-until` | absent | Latest disclosed date, on or after `--source-since` | +| `--allow-sensitive` | explicit decision required | `true` permits archive evidence; `false` permits only synthetic checks | +| `--yes` | `false` | Confirm the displayed check disclosure and select the profile without a consent prompt | + +Host-side `person provider login` reauthenticates the selected existing Codex +profile; `person provider models` lists its models and reasoning efforts. Both +accept `--json` and remain unavailable behind the same release gate. See +[Codex configuration](configuration.md#codex-app-server-profiles). + ## person provider status Show the exact people inference provider policy and its check and consent state. @@ -2919,9 +2977,11 @@ Configure optional search and people features from the available API keys. The command reads `VOYAGE_API_KEY`, `OPENAI_API_KEY`, and the document provider's configured key variable (default `MISTRAL_API_KEY`). For an unset text feature, it chooses Voyage contextual embeddings, then OpenAI embeddings, then an -available loopback Ollama model at `[chat].server`. For inference, it chooses -OpenAI, then the configured local Ollama chat model. These are setup choices; -the running daemon does not fall back to another provider after a failure. +available loopback Ollama model at `[chat].server`. Hosted people inference +requires an explicit `--provider` and `--model`; an OpenAI key alone does not +select it. Without an OpenAI key or explicit provider choice, setup can offer +the configured local Ollama chat model. The running daemon does not fall back +to another provider after a failure. Setup prints a plan, asks once per hosted provider, writes `config.toml`, and prints the remaining commands. It onboards a new people-sweep provider through @@ -2930,10 +2990,14 @@ consents remain separate. Visual search needs a valid probe manifest before setup enables it; document extraction remains manual. See [Recommended Configuration](usage/recommended-configuration.md). -The people sweep stays pending unless `--allow-sensitive` is supplied. This -permits sending sensitive archive excerpts to its inference provider and -inferring sensitive personal attributes. `--yes` alone does not grant this -permission. Vector lanes also stay pending when the binary lacks the backend +Local people-sweep setup stays pending unless `--allow-sensitive` is supplied. +An explicit `--provider` requires `--allow-sensitive=true|false` and explicit +`--retention-posture` and `--training-posture` assertions. Only `true` permits +sending sensitive archive excerpts and inferring sensitive personal attributes; +`false` permits the synthetic check but real sweeps cannot process evidence. +`--yes` alone does not grant this permission. If a sweep is already enabled, +`--provider` is rejected; use `person provider add` and `person provider use` +to choose another profile. Vector lanes also stay pending when the binary lacks the backend required by the configured database; setup prints rebuild guidance. Saved retention and training postures on disabled lanes are preserved unless @@ -2958,6 +3022,10 @@ msgvault setup providers --yes --document-retention zdr --document-training opte | Flag | Default | Description | | ---------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--provider` | absent | Explicit people inference preset: `openai`, `openrouter`, or `venice`; requires model and privacy decisions | +| `--model` | absent | Required model ID for the selected preset | +| `--credential-env` | absent | Host environment-variable name for the preset key; mutually exclusive with `--api-key-stdin` | +| `--api-key-stdin` | `false` | Read the preset API key from standard input; otherwise a terminal can prompt for it | | `--yes` | `false` | Accept every provider disclosure without prompting. Required to apply a plan with hosted-provider prompts when stdin is not a terminal or `--json` is used. It does not grant the separate sensitive-data opt-in. | | `--allow-sensitive` | `false` | Allow the people sweep to send sensitive archive excerpts and infer sensitive personal attributes | | `--dry-run` | `false` | Print the plan, the disclosures, and the current lane report without writing | diff --git a/docs/configuration.md b/docs/configuration.md index e7d11ad1e..2895ec2ce 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -58,8 +58,10 @@ values remain in effect. Provider keys alone do not enable processing. People sweeps use one named protocol profile at a time. A profile records the exact endpoint, model, wire protocol, negotiated output mode, privacy posture, -and source scope. It is configuration, not a provider preset. Msgvault never -changes the active profile or switches providers automatically. +and source scope. Built-in OpenAI, OpenRouter, and Venice presets bind the +protocol, endpoint, and authentication scheme; you still choose the model and +privacy policy. Msgvault never changes the active profile or switches providers +automatically. ```toml [people.sweep] @@ -98,7 +100,8 @@ Codex app-server profiles section below. Onboarding negotiates and saves also save either `max_completion_tokens` or `max_tokens`; the other protocols use their defined token-limit field. -These are examples of protocol profiles, not built-in presets: +Other providers use explicit protocol profiles; OpenRouter, Venice, and OpenAI +also have built-in presets: | Example profile | Protocol | Typical profile choice | |---|---|---| @@ -121,7 +124,12 @@ their provider terms. Credentials are not stored in this TOML. `credential = "stored"` keeps a profile-specific secret under the private tokens directory and is supported on Linux and macOS only; `credential = "env"` stores only the selected -environment-variable name and works everywhere. +environment-variable name and works everywhere. Environment-variable names are +host-only settings: configure them through the CLI or TOML, not the Web UI. +On hosts without stored-key support, the Web UI hides profile enrollment and +key fields. Run [`msgvault person provider add`](cli-reference.md#person-provider-add) +with `--credential-env` on the daemon host, then reload the Web settings to +check and select the profile. `credential = "none"` is restricted to credentialless local or Codex paths. Changing a credential value does not change the profile fingerprint, but changing its source or reference does. @@ -149,7 +157,7 @@ same provider and schedule, with separate enrollment and interval controls. | Key | Default | Description | | ------------------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `enabled` | `false` | Run the scheduled people sweep with the selected provider. | -| `provider` | `default` | Name of a table under `[people.sweep.providers]`. The initial profile has an OpenAI endpoint but no model; it is not a usable, consented provider. Setup creates and selects `openai` or `ollama`. | +| `provider` | `default` | Name of a table under `[people.sweep.providers]`. The initial profile has an OpenAI endpoint but no model; it is not a usable, consented provider. Setup can explicitly select `openai`, `openrouter`, or `venice`, or configure local `ollama`. | | `schedule` | `15 2 * * *` | Daily at 02:15 in the daemon's time zone. An omitted or empty value receives this default; use `enabled = false` to disable the sweep. | | `work_batch_size` | `25` | Tracked people considered in one worker batch. | | `historical_message_cap` | `2000` | Maximum archived messages considered when finding context for each profile field. | @@ -158,12 +166,14 @@ same provider and schedule, with separate enrollment and interval controls. | `evidence_max_items` | `200` | Item limit for an evidence packet. | | `backstop_interval` | `24h` | Interval before checking tracked people for changes missed by incremental work. | -`setup providers --allow-sensitive` uses `gpt-5.6-luna` with `medium` reasoning -when an OpenAI key is present, or the configured local Ollama chat model -otherwise. It preserves an existing active profile and never switches after a -request failure. Without `--allow-sensitive`, setup leaves inference pending: -the same profile flag controls both sensitive archive evidence and sensitive -attribute targets. +Hosted people inference requires an explicit `setup providers --provider + --model ` choice, a credential source, and +explicit retention, training, and sensitive-content decisions. An +`OPENAI_API_KEY` alone configures only eligible embedding lanes. With no OpenAI +key or explicit provider choice, setup can offer the configured loopback Ollama +chat model when `--allow-sensitive` is supplied. An enabled sweep is preserved; +`--provider` then fails with instructions to use `person provider add` and +`person provider use`. See [setup flags](cli-reference.md#setup-providers). ### `[people.sweep.budgets]` @@ -231,7 +241,11 @@ The `codex_app_server` protocol is not usable in this release. Its transport stays unavailable until the executable isolation gate releases a verified build, and until then every Codex operation fails closed with `codex app-server isolation is not released`. The profile shape is documented -here so the configuration is ready when the gate ships. +here so the configuration is ready when the gate ships. Codex sign-in and +model routes return HTTP 503 before changing credentials or consent. The +terminal-only [`person provider enroll-codex`](cli-reference.md#person-provider-enroll-codex) +command creates a new profile through the daemon; host-side `person provider +login` reauthenticates the selected existing Codex profile. Both remain gated. `codex_app_server` profiles are also the one protocol `person provider add` cannot create: generic onboarding negotiates HTTP capabilities through an diff --git a/docs/setup.md b/docs/setup.md index cd1badba6..cf407027b 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -336,13 +336,13 @@ TUI](/docs/usage/tui/) for more. ## Optional: Turn On Search and People Lanes Semantic search, visual and document attachment search, and the people sweep -are opt-in. Put the API keys you have in the environment and let setup choose -the rest: +are opt-in. Put your API keys in the environment so setup can propose search +features. Hosted people inference also needs an explicit provider and model: ```bash export VOYAGE_API_KEY="..." # text, people, and visual search export MISTRAL_API_KEY="..." # document attachments -export OPENAI_API_KEY="..." # people sweep (and text search without a Voyage key) +export OPENAI_API_KEY="..." # text search without a Voyage key msgvault setup providers # review provider disclosures and write opt-in config msgvault setup status # what is on, what is off, and why ``` @@ -351,8 +351,17 @@ Message embeddings are authorized by enabling their configuration and have no separate stored consent record. Semantic people search, visual processing, document extraction and vectors, and people sweeps have separate consent gates; `setup status` names any command still required. The people sweep additionally -requires `msgvault setup providers --allow-sensitive` to permit sensitive -archive excerpts and sensitive personal inferences. +requires an explicit hosted provider choice, for example: + +```bash +msgvault setup providers --provider openai --model \ + --credential-env OPENAI_API_KEY --retention-posture \ + --training-posture --allow-sensitive +``` + +`--allow-sensitive` permits sensitive archive excerpts and sensitive personal +inferences. A key alone never selects a hosted people provider. Codex enrollment +remains unavailable until an approved build ships. See [Recommended Configuration](/docs/usage/recommended-configuration/) for the values it writes and the probe steps the hosted lanes still need. diff --git a/docs/usage/recommended-configuration.md b/docs/usage/recommended-configuration.md index bd6aa7aa6..ace69a590 100644 --- a/docs/usage/recommended-configuration.md +++ b/docs/usage/recommended-configuration.md @@ -1,5 +1,5 @@ --- -last_edited: "2026-09-15" +last_edited: "2026-09-25" title: Recommended Configuration description: Set up optional search and people features from your available provider keys, then check what still needs attention. --- @@ -16,11 +16,13 @@ vectors, or people sweeps. Each has its own readiness and consent checks. ```bash export VOYAGE_API_KEY="..." # text, people, and visual search export MISTRAL_API_KEY="..." # document attachments -export OPENAI_API_KEY="..." # people sweep (and text search when no Voyage key) +export OPENAI_API_KEY="..." # text search when no Voyage key msgvault setup providers --dry-run # show the plan and each provider disclosure msgvault setup providers # answer once per provider, write config.toml -msgvault setup providers --allow-sensitive # opt into sensitive evidence for the people sweep +msgvault setup providers --provider openai --model \ + --credential-env OPENAI_API_KEY --retention-posture \ + --training-posture --allow-sensitive # explicit people inference choice msgvault setup status # what is on, what is off, and why ``` @@ -48,7 +50,7 @@ conversation briefs. | --------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `VOYAGE_API_KEY` | text search, semantic people search, visual attachments (after the probe) | `voyage-context-4` (1024), `voyage-multimodal-3.5` (1024) | Beeper chats use conversation windows; meetings use speaker turns. Other messages share the generation as individual documents. | | `MISTRAL_API_KEY` | document extraction and lexical search; document vectors when a text lane is on | `mistral-ocr-4-0`, EU region | Uploads are manual-only and need the probe manifest plus `documents consent-mistral --yes`. | -| `OPENAI_API_KEY` | people sweep with `--allow-sensitive`; text search only when no Voyage key | `gpt-5.6-luna` at `medium` reasoning; `text-embedding-3-small` (1536) | The OpenAI text path gives per-message vectors: no conversation-window context and no visual lane, both are Voyage-only endpoints. | +| `OPENAI_API_KEY` | text search only when no Voyage key | `text-embedding-3-small` (1536) | The OpenAI text path gives per-message vectors: no conversation-window context and no visual lane, both are Voyage-only endpoints. | | no key for the feature being configured | loopback Ollama at `[chat].server` when reachable | `nomic-embed-text` (768); the `[chat].model` for the sweep with `--allow-sensitive` | Text uses Ollama only without Voyage or OpenAI keys. Inference uses it without an OpenAI key, even when Voyage or Mistral is configured. The required model must already be installed. | These are setup choices, not runtime failover. Adding a Voyage key does not @@ -61,8 +63,9 @@ when the selected API or local model is not the one you want. ## The file setup writes -With a Voyage key, a Mistral key, and an OpenAI key present and -`--allow-sensitive` supplied, successful setup produces the configuration below. +With Voyage, Mistral, and OpenAI keys present, the explicit OpenAI setup command +above produces the configuration below when `` is `gpt-5.6-luna` and both +privacy assertions are `provider-declared`. The example assumes SQLite, no probe manifests yet, and a people-provider check that negotiates native JSON output. The saved output mode and token-limit field come from that check. Comments and sections you already have are preserved. @@ -107,6 +110,7 @@ enabled = true provider = "openai" [people.sweep.providers.openai] +preset_id = "openai" protocol = "openai_chat" endpoint = "https://api.openai.com/v1" model = "gpt-5.6-luna" @@ -115,7 +119,6 @@ credential = "env" credential_env = "OPENAI_API_KEY" output_mode = "native_json_schema" token_limit_parameter = "max_completion_tokens" -reasoning_effort = "medium" retention_posture = "provider-declared" training_posture = "provider-declared" allowed_sources = ["conversation_text", "meeting_text", "document_text"] @@ -200,14 +203,14 @@ consent separately and lists each missing consent command. The sweep maintains profile facts from archive evidence for people you track (`msgvault person track `). Deterministic contact state (last contacted, cadence, inferred channel) refreshes hourly for everyone through -`[activity]` and needs no model. Setup onboards the `openai` profile through -`person provider add` (a synthetic check request is sent), records consent, and -selects it. The default sweep schedule is daily at `02:15` in the daemon's time +`[activity]` and needs no model. With `--provider openai`, setup onboards the +`openai` profile through `person provider add` (a synthetic check request is +sent), records consent, and selects it. The default sweep schedule is daily at `02:15` in the daemon's time zone. Setup preserves any saved sweep schedule. `allow_sensitive = true` is required for real sweeps because every archive evidence packet is marked -sensitive. Setup sets it only when you pass `--allow-sensitive`; without that -flag it leaves the sweep unconfigured. The same flag permits inference of -sensitive attributes; there is no separate setup flag for allowing private +sensitive. Hosted setup requires an explicit `--allow-sensitive=true|false` +decision; `false` allows setup and synthetic checks but cannot run real sweeps. +The same flag permits inference of sensitive attributes; there is no separate setup flag for allowing private source text while excluding sensitive targets. With no OpenAI key, setup offers a loopback Ollama profile on `[chat].model`. @@ -315,10 +318,11 @@ hosts over HTTPS and loopback servers. Other hosted endpoints are custom: configure their people-search and document-vector lanes explicitly, then review the separate consent commands for the new data they will receive. -The people sweep stays pending without `--allow-sensitive`, even with `--yes`. -The flag permits sending sensitive archive excerpts to the inference provider -and inferring sensitive personal attributes. The plan describes this policy in -both human and JSON output. +Local people-sweep setup stays pending without `--allow-sensitive`, even with +`--yes`. Hosted setup also requires an explicit preset, model, credential source, +and retention and training assertions. The sensitive-content flag permits +sending archive excerpts to the provider and inferring sensitive personal +attributes. The plan describes this policy in both human and JSON output. ## Reading the status report diff --git a/internal/api/carddav.go b/internal/api/carddav.go index a80c957ff..f5a6fda90 100644 --- a/internal/api/carddav.go +++ b/internal/api/carddav.go @@ -971,7 +971,7 @@ func registerCardDAVJSONRouteWithRequest[Req, Resp any](api huma.API, operationI func cardDAVIDOperation(operationID, method, path, parameter, summary string) huma.Operation { op := rawAPIV1Operation(operationID, method, path, summary) minimum := float64(1) - op.Parameters = append(op.Parameters, &huma.Param{Name: parameter, In: "path", Required: true, + op.Parameters = append(op.Parameters, &huma.Param{Name: parameter, In: pathKey, Required: true, Schema: &huma.Schema{Type: huma.TypeInteger, Format: formatInt64, Minimum: &minimum}}) return op } diff --git a/internal/api/files.go b/internal/api/files.go index e308bfe79..324af5811 100644 --- a/internal/api/files.go +++ b/internal/api/files.go @@ -207,7 +207,7 @@ func registerPersonFileRoute( op.Tags = []string{"Exploration"} if idDescription != "" { op.Parameters = append(op.Parameters, &huma.Param{ - Name: "id", In: "path", Required: true, Description: idDescription, + Name: "id", In: pathKey, Required: true, Description: idDescription, Schema: &huma.Schema{Type: huma.TypeInteger, Format: formatInt64}, }) } diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 27a7d5b19..c206a7e3f 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -2211,7 +2211,7 @@ func viewTypeString(v query.ViewType) string { // Accepted values for enum query parameters, surfaced in 400 messages. var ( - aggregateSortFields = []string{"count", "size", "attachment_size", "name"} + aggregateSortFields = []string{"count", "size", "attachment_size", nameKey} messageSortFields = []string{activityDateField, "size", "subject"} textSortFields = []string{"last_message", "count", "name"} sortDirections = []string{"asc", apiSortDirectionDesc} diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 9b7185b3b..07ff58a54 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -64,6 +64,7 @@ const ( ifMatchHeaderName = "If-Match" headerParamLocation = "header" pathKey = "path" + nameKey = "name" // formatInt64 is the OpenAPI schema format for 64-bit identifiers. formatInt64 = "int64" diff --git a/internal/api/operation_gate.go b/internal/api/operation_gate.go index ccbdb72b9..c6b0461d7 100644 --- a/internal/api/operation_gate.go +++ b/internal/api/operation_gate.go @@ -466,6 +466,12 @@ func operationGateRequest(r *http.Request, auth requestAuthentication) (bool, st if readOnlyPostRouteRequest(r) { return false, "", nil } + // Provider handlers take the gate only for their local mutations. Device + // authorization and synthetic network checks must not block archive work. + if r.Method == http.MethodPost && (r.URL.Path == "/api/v1/settings/people-inference/codex/login" || + (strings.HasPrefix(r.URL.Path, "/api/v1/settings/people-inference/providers/") && strings.HasSuffix(r.URL.Path, "/check"))) { + return false, "", nil + } if r.URL.Path == "/api/v1/cli/repair-message" { label, skip, err := cliRepairMessageGateDecision(r) if err != nil { diff --git a/internal/api/people_codex_login.go b/internal/api/people_codex_login.go new file mode 100644 index 000000000..11bf6dbe6 --- /dev/null +++ b/internal/api/people_codex_login.go @@ -0,0 +1,161 @@ +package api + +import ( + "context" + "errors" + "sync" + "time" + + "go.kenn.io/msgvault/internal/peoplesweep" +) + +var errPeopleCodexLoginBusy = errors.New("a Codex device login is already active") +var errPeopleCodexLoginPreparation = errors.New("codex device login preparation failed") +var errPeopleCodexLoginCompleted = errors.New("codex device login completed before cancellation") + +type peopleCodexLoginClient interface { + StartDeviceLogin(ctx context.Context, present func(peoplesweep.DeviceLogin) error) error + ListModels(ctx context.Context) ([]peoplesweep.CodexModel, error) +} + +type peopleCodexLoginSession struct { + draft peoplesweep.EnrollmentDraft + owner string + name string + state string + login peoplesweep.DeviceLogin + cancel context.CancelFunc + done chan struct{} + result error +} + +// peopleCodexLogins owns one active device ceremony for the daemon's single +// private Codex auth home. A completed login keeps blocking new ceremonies +// until its draft is consumed, cancelled, or expired, because its commit +// replaced the shared credential and the owner may still be listing models +// or creating the profile under that account. The draft ID is never a bearer +// credential: every lookup also checks the authenticated caller's session +// identity. +type peopleCodexLogins struct { + mu sync.Mutex + drafts *peoplesweep.EnrollmentDrafts + sessions map[string]*peopleCodexLoginSession + client peopleCodexLoginClient +} + +func newPeopleCodexLogins(client peopleCodexLoginClient, now func() time.Time) *peopleCodexLogins { + return &peopleCodexLogins{ + drafts: peoplesweep.NewEnrollmentDrafts(now), sessions: make(map[string]*peopleCodexLoginSession), + client: client, + } +} + +func (m *peopleCodexLogins) Start(owner, name string, beforeStart func() error) (peoplesweep.EnrollmentDraft, error) { + if err := peoplesweep.ValidateProviderProfileName(name); err != nil { + return peoplesweep.EnrollmentDraft{}, err + } + if m.client == nil { + return peoplesweep.EnrollmentDraft{}, errors.New("codex enrollment is unavailable") + } + m.mu.Lock() + defer m.mu.Unlock() + for id, existing := range m.sessions { + if _, err := m.drafts.Get(existing.owner, id); err != nil { + existing.cancel() + delete(m.sessions, id) + continue + } + if existing.state == "pending" || existing.state == "complete" { + // A completed but unconsumed login still owns the shared auth + // home; a second device login would silently replace its account + // before the profile is created. + return peoplesweep.EnrollmentDraft{}, errPeopleCodexLoginBusy + } + // A failed ceremony never committed a credential, so it cannot + // conflict with a fresh login. + existing.cancel() + delete(m.sessions, id) + } + draft, err := m.drafts.Create(owner, "codex") + if err != nil { + return peoplesweep.EnrollmentDraft{}, err + } + if beforeStart != nil { + if err := beforeStart(); err != nil { + _ = m.drafts.Cancel(owner, draft.ID) + return peoplesweep.EnrollmentDraft{}, errors.Join(errPeopleCodexLoginPreparation, err) + } + } + ctx, cancel := context.WithDeadline(context.Background(), draft.ExpiresAt) + session := &peopleCodexLoginSession{draft: draft, owner: owner, name: name, state: "pending", cancel: cancel, done: make(chan struct{})} + m.sessions[draft.ID] = session + go m.run(ctx, session) + return draft, nil +} + +func (m *peopleCodexLogins) run(ctx context.Context, session *peopleCodexLoginSession) { + defer session.cancel() + err := m.client.StartDeviceLogin(ctx, func(login peoplesweep.DeviceLogin) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.sessions[session.draft.ID] != session || ctx.Err() != nil { + return context.Canceled + } + session.login = login + return nil + }) + m.mu.Lock() + defer close(session.done) + defer m.mu.Unlock() + session.result = err + if m.sessions[session.draft.ID] != session { + return + } + if err != nil { + session.state = "failed" + } else { + session.state = "complete" + } +} + +func (m *peopleCodexLogins) Get(owner, id string) (peopleCodexLoginSession, error) { + m.mu.Lock() + defer m.mu.Unlock() + if _, err := m.drafts.Get(owner, id); err != nil { + return peopleCodexLoginSession{}, err + } + session := m.sessions[id] + if session == nil || session.owner != owner { + return peopleCodexLoginSession{}, peoplesweep.ErrEnrollmentDraftNotFound + } + snapshot := *session + snapshot.cancel = nil + return snapshot, nil +} + +func (m *peopleCodexLogins) Cancel(owner, id string) error { + m.mu.Lock() + if _, err := m.drafts.Get(owner, id); err != nil { + m.mu.Unlock() + return err + } + session := m.sessions[id] + if session == nil || session.owner != owner { + m.mu.Unlock() + return peoplesweep.ErrEnrollmentDraftNotFound + } + wasPending := session.state == "pending" + session.cancel() + m.mu.Unlock() + <-session.done + m.mu.Lock() + defer m.mu.Unlock() + if err := m.drafts.Cancel(owner, id); err != nil { + return err + } + delete(m.sessions, id) + if wasPending && session.result == nil { + return errPeopleCodexLoginCompleted + } + return nil +} diff --git a/internal/api/people_codex_login_test.go b/internal/api/people_codex_login_test.go new file mode 100644 index 000000000..eaad88b21 --- /dev/null +++ b/internal/api/people_codex_login_test.go @@ -0,0 +1,344 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/config" + "go.kenn.io/msgvault/internal/peoplesweep" + "go.kenn.io/msgvault/internal/personenrollment" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil" +) + +type pendingCodexLoginClient struct { + started chan struct{} + ended chan struct{} + cancelSeen chan struct{} + finish chan struct{} +} + +type completedCodexLoginClient struct{} + +func (completedCodexLoginClient) StartDeviceLogin(_ context.Context, present func(peoplesweep.DeviceLogin) error) error { + return present(peoplesweep.DeviceLogin{ + VerificationURL: "https://example.test/device", UserCode: "ABCD-EFGH", + ExpiresAt: time.Now().Add(time.Minute), + }) +} + +func (completedCodexLoginClient) ListModels(context.Context) ([]peoplesweep.CodexModel, error) { + return []peoplesweep.CodexModel{{ID: "gpt-test", SupportedEfforts: []string{"medium", "high"}}}, nil +} + +func (c pendingCodexLoginClient) StartDeviceLogin(ctx context.Context, present func(peoplesweep.DeviceLogin) error) error { + defer close(c.ended) + if err := present(peoplesweep.DeviceLogin{ + VerificationURL: "https://example.test/device", UserCode: "ABCD-EFGH", + ExpiresAt: time.Now().Add(time.Minute), + }); err != nil { + return err + } + close(c.started) + <-ctx.Done() + if c.cancelSeen != nil { + close(c.cancelSeen) + } + if c.finish != nil { + <-c.finish + } + return ctx.Err() +} + +func (pendingCodexLoginClient) ListModels(context.Context) ([]peoplesweep.CodexModel, error) { + return nil, nil +} + +func TestPeopleCodexLoginsBindOwnerAndCancelActiveDeviceFlow(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + client := pendingCodexLoginClient{started: make(chan struct{}), ended: make(chan struct{})} + manager := newPeopleCodexLogins(client, time.Now) + draft, err := manager.Start("browser-a", "codex-main", nil) + require.NoError(err) + select { + case <-client.started: + case <-time.After(5 * time.Second): + require.FailNow("device code was not presented") + } + _, err = manager.Get("browser-b", draft.ID) + require.ErrorIs(err, peoplesweep.ErrEnrollmentDraftNotFound) + preparationCalls := 0 + _, err = manager.Start("browser-b", "codex-other", func() error { + preparationCalls++ + return nil + }) + require.ErrorIs(err, errPeopleCodexLoginBusy) + _, err = manager.Start("browser-b", "bad name", func() error { + preparationCalls++ + return nil + }) + require.Error(err) + assert.Zero(preparationCalls) + status, err := manager.Get("browser-a", draft.ID) + require.NoError(err) + assert.Equal("pending", status.state) + assert.Equal("ABCD-EFGH", status.login.UserCode) + require.ErrorIs(manager.Cancel("browser-b", draft.ID), peoplesweep.ErrEnrollmentDraftNotFound) + require.NoError(manager.Cancel("browser-a", draft.ID)) + select { + case <-client.ended: + case <-time.After(5 * time.Second): + require.FailNow("device login did not stop after cancellation") + } + _, err = manager.Get("browser-a", draft.ID) + require.ErrorIs(err, peoplesweep.ErrEnrollmentDraftNotFound) +} + +func TestPeopleCodexLoginsBlockSecondLoginWhileCompletedSessionUnconsumed(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + manager := newPeopleCodexLogins(completedCodexLoginClient{}, time.Now) + draft, err := manager.Start("browser-a", "codex-main", nil) + require.NoError(err) + status, err := manager.Get("browser-a", draft.ID) + require.NoError(err) + select { + case <-status.done: + case <-time.After(5 * time.Second): + require.FailNow("device login did not complete") + } + status, err = manager.Get("browser-a", draft.ID) + require.NoError(err) + assert.Equal("complete", status.state) + preparationCalls := 0 + _, err = manager.Start("browser-b", "codex-other", func() error { + preparationCalls++ + return nil + }) + require.ErrorIs(err, errPeopleCodexLoginBusy) + assert.Zero(preparationCalls) + // Consuming the completed session by cancelling its draft unblocks a + // fresh ceremony. + require.NoError(manager.Cancel("browser-a", draft.ID)) + second, err := manager.Start("browser-b", "codex-other", nil) + require.NoError(err) + require.NoError(manager.Cancel("browser-b", second.ID)) +} + +func TestPeopleCodexLoginCancellationWaitsForCredentialCommitToStop(t *testing.T) { + require := require.New(t) + client := pendingCodexLoginClient{ + started: make(chan struct{}), ended: make(chan struct{}), + cancelSeen: make(chan struct{}), finish: make(chan struct{}), + } + manager := newPeopleCodexLogins(client, time.Now) + draft, err := manager.Start("browser-a", "codex-main", nil) + require.NoError(err) + <-client.started + result := make(chan error, 1) + go func() { result <- manager.Cancel("browser-a", draft.ID) }() + select { + case <-client.cancelSeen: + case <-time.After(5 * time.Second): + require.FailNow("login cancellation did not reach client") + } + select { + case err := <-result: + require.FailNow("cancellation returned before client stopped", err) + case <-time.After(20 * time.Millisecond): + } + close(client.finish) + require.NoError(<-result) +} + +func TestPeopleCodexLoginAPIRoutesKeepDeviceCodeOutOfPollAndCancel(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + srv, _ := newSettingsTestServer(t, "") + client := pendingCodexLoginClient{started: make(chan struct{}), ended: make(chan struct{})} + srv.peopleCodexLogins = newPeopleCodexLogins(client, time.Now) + started := performSettingsRequest(t, srv, http.MethodPost, + peopleInferenceSettingsPath+"/codex/login", []byte(`{"name":"codex-main"}`), "", "") + require.Equal(http.StatusOK, started.Code, started.Body.String()) + var login PeopleCodexLoginResponse + require.NoError(json.Unmarshal(started.Body.Bytes(), &login)) + assert.Equal("https://example.test/device", login.VerificationURL) + assert.Equal("ABCD-EFGH", login.UserCode) + assert.NotEmpty(login.SessionID) + poll := performSettingsRequest(t, srv, http.MethodGet, + peopleInferenceSettingsPath+"/codex/login/"+login.SessionID, nil, "", "") + require.Equal(http.StatusOK, poll.Code, poll.Body.String()) + assert.Contains(poll.Body.String(), `"state":"pending"`) + assert.NotContains(poll.Body.String(), login.UserCode) + models := performSettingsRequest(t, srv, http.MethodGet, + peopleInferenceSettingsPath+"/codex/login/"+login.SessionID+"/models", nil, "", "") + assert.Equal(http.StatusConflict, models.Code) + cancelled := performSettingsRequest(t, srv, http.MethodDelete, + peopleInferenceSettingsPath+"/codex/login/"+login.SessionID, nil, "", "") + require.Equal(http.StatusOK, cancelled.Code, cancelled.Body.String()) + assert.NotContains(cancelled.Body.String(), login.UserCode) + select { + case <-client.ended: + case <-time.After(5 * time.Second): + require.FailNow("device login did not stop after API cancellation") + } +} + +func TestPeopleCodexLoginAPICreatesProfileOnlyAfterExactModelDiscovery(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + if runtime.GOOS == "windows" { + t.Skip("the private codex auth home layout requires Unix permission bits") + } + srv, _ := newSettingsTestServer(t, "") + srv.peopleCodexLogins = newPeopleCodexLogins(completedCodexLoginClient{}, time.Now) + started := performSettingsRequest(t, srv, http.MethodPost, + peopleInferenceSettingsPath+"/codex/login", []byte(`{"name":"subscription"}`), "", "") + require.Equal(http.StatusOK, started.Code, started.Body.String()) + var login PeopleCodexLoginResponse + require.NoError(json.Unmarshal(started.Body.Bytes(), &login)) + settings := performSettingsRequest(t, srv, http.MethodGet, peopleInferenceSettingsPath, nil, "", "") + require.Equal(http.StatusOK, settings.Code) + badModel := performSettingsRequest(t, srv, http.MethodPut, + peopleInferenceSettingsPath+"/codex/login/"+login.SessionID+"/profile", + []byte(`{"model":"unknown","reasoning_effort":"high","retention_posture":"operator-confirmed","training_posture":"operator-confirmed","allowed_sources":["conversation_text"],"source_since":"2025-01-01","allow_sensitive":false}`), + settings.Header().Get("ETag"), "") + assert.Equal(http.StatusUnprocessableEntity, badModel.Code) + created := performSettingsRequest(t, srv, http.MethodPut, + peopleInferenceSettingsPath+"/codex/login/"+login.SessionID+"/profile", + []byte(`{"model":"gpt-test","reasoning_effort":"high","retention_posture":"operator-confirmed","training_posture":"operator-confirmed","allowed_sources":["conversation_text"],"source_since":"2025-01-01","allow_sensitive":false}`), + settings.Header().Get("ETag"), "") + require.Equal(http.StatusOK, created.Code, created.Body.String()) + var response PeopleInferenceSettingsResponse + require.NoError(json.Unmarshal(created.Body.Bytes(), &response)) + var found bool + for _, profile := range response.Profiles { + if profile.Name == "subscription" { + found = true + assert.Equal(string(peoplesweep.ProtocolCodexAppServer), profile.Protocol) + assert.Equal("gpt-test", profile.Model) + assert.False(profile.CredentialConfigured) + assert.False(profile.ConsentActive) + } + } + assert.True(found) + authHome := filepath.Join(srv.cfg.TokensDir(), "people-codex") + require.NoError(os.MkdirAll(authHome, 0o700)) + require.NoError(os.WriteFile(filepath.Join(authHome, "auth.json"), []byte(`{"synthetic":true}`), 0o600)) + read := performSettingsRequest(t, srv, http.MethodGet, peopleInferenceSettingsPath, nil, "", "") + require.Equal(http.StatusOK, read.Code) + var authenticated PeopleInferenceSettingsResponse + require.NoError(json.Unmarshal(read.Body.Bytes(), &authenticated)) + for _, profile := range authenticated.Profiles { + if profile.Name == "subscription" { + assert.True(profile.CredentialConfigured) + } + } +} + +func TestPeopleCodexLoginRevokesPriorAccountAuthorityBeforeDeviceFlow(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + srv, path := newSettingsTestServer(t, "") + st := testutil.NewTestStore(t) + srv.store = st + before, err := config.ReadConfigFile(path) + require.NoError(err) + provider := peoplesweep.ProviderConfig{ + Protocol: peoplesweep.ProtocolCodexAppServer, Model: "gpt-test", ReasoningEffort: "high", + Auth: peoplesweep.AuthNone, Credential: peoplesweep.CredentialNone, + OutputMode: peoplesweep.OutputModeNativeJSONSchema, Executable: "codex", + ExecutionBoundary: peoplesweep.CodexExecutionBoundaryV1, + RetentionPosture: "operator-confirmed", TrainingPosture: "operator-confirmed", + AllowedSources: []peoplesweep.SourceClass{peoplesweep.SourceConversationText}, + SourceSince: "2025-01-01", RequestTimeout: time.Minute, + } + created, err := personenrollment.NewService(path, st).CreateProfile(before.ETag, "subscription", provider) + require.NoError(err) + configured, err := config.Load(path, "") + require.NoError(err) + profileConfig := configured.People.Sweep + profileConfig.Provider = peoplesweep.ProviderSelection{Name: "subscription"} + profileConfig.Enabled = true + profile, err := profileConfig.Profile() + require.NoError(err) + _, err = st.EnsurePersonInferenceProfile(t.Context(), profile) + require.NoError(err) + require.NoError(st.RecordPersonInferenceCheck(t.Context(), store.PersonInferenceCheck{ + ProfileFingerprint: profile.Fingerprint, CheckedAt: time.Now(), + DriverVersion: profile.DriverVersion, OutputMode: profile.OutputMode, + ModelVersion: profile.Model, + })) + _, _, err = st.GrantPersonInferenceConsent(t.Context(), profile.Fingerprint, "test") + require.NoError(err) + if !peoplesweep.CodexReleaseAvailable() { + check := performSettingsRequest(t, srv, http.MethodPost, + peopleInferenceSettingsPath+"/providers/subscription/check", nil, created.ETag, "") + assert.Equal(http.StatusServiceUnavailable, check.Code, check.Body.String()) + login := performSettingsRequest(t, srv, http.MethodPost, + peopleInferenceSettingsPath+"/codex/login", []byte(`{"name":"replacement"}`), "", "") + assert.Equal(http.StatusServiceUnavailable, login.Code, login.Body.String()) + consented, err := st.HasActivePersonInferenceConsent(t.Context(), profile.Fingerprint) + require.NoError(err) + assert.True(consented) + // The remaining protocol test injects a client without launching Codex. + srv = NewServer(srv.cfg, st, nil, srv.logger) + } + srv.peopleCodexLogins = newPeopleCodexLogins(completedCodexLoginClient{}, time.Now) + duplicate := performSettingsRequest(t, srv, http.MethodPost, + peopleInferenceSettingsPath+"/codex/login", []byte(`{"name":"subscription"}`), "", "") + assert.Equal(http.StatusConflict, duplicate.Code, duplicate.Body.String()) + invalid := performSettingsRequest(t, srv, http.MethodPost, + peopleInferenceSettingsPath+"/codex/login", []byte(`{"name":"bad name"}`), "", "") + assert.Equal(http.StatusBadRequest, invalid.Code) + stillConsented, err := st.HasActivePersonInferenceConsent(t.Context(), profile.Fingerprint) + require.NoError(err) + assert.True(stillConsented) + started := performSettingsRequest(t, srv, http.MethodPost, + peopleInferenceSettingsPath+"/codex/login", []byte(`{"name":"replacement"}`), "", "") + require.Equal(http.StatusOK, started.Code, started.Body.String()) + consented, err := st.HasActivePersonInferenceConsent(t.Context(), profile.Fingerprint) + require.NoError(err) + assert.False(consented) + checked, err := st.HasSuccessfulPersonInferenceCheck(t.Context(), profile.Fingerprint) + require.NoError(err) + assert.False(checked) +} + +func TestPeopleCodexLoginUnavailableLeavesCredentialsAndConfigUntouched(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + if peoplesweep.CodexReleaseAvailable() { + t.Skip("this build has an approved Codex release") + } + srv, path := newSettingsTestServer(t, "") + before, err := config.ReadConfigFile(path) + require.NoError(err) + for _, request := range []struct{ method, path, body string }{ + {http.MethodPost, "/codex/login", `{"name":"subscription"}`}, + {http.MethodGet, "/codex/login/example", ""}, + {http.MethodDelete, "/codex/login/example", ""}, + {http.MethodGet, "/codex/login/example/models", ""}, + {http.MethodPut, "/codex/login/example/profile", `{}`}, + } { + response := performSettingsRequest(t, srv, request.method, peopleInferenceSettingsPath+request.path, + []byte(request.body), before.ETag, "") + assert.Equal(http.StatusServiceUnavailable, response.Code, response.Body.String()) + assert.Contains(response.Body.String(), "codex_unavailable") + } + authHome := filepath.Join(srv.cfg.TokensDir(), "people-codex") + _, err = os.Lstat(authHome) + require.ErrorIs(err, os.ErrNotExist) + after, err := config.ReadConfigFile(path) + require.NoError(err) + assert.Equal(before.ETag, after.ETag) +} diff --git a/internal/api/server.go b/internal/api/server.go index f6cfa11d8..f1252ad5e 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -323,7 +323,10 @@ type Server struct { ftsRebuildGen atomic.Uint64 // settingsPendingRestart remains set after the first successful browser // config edit for the lifetime of this daemon process. - settingsPendingRestart atomic.Bool + settingsPendingRestart atomic.Bool + peopleCodexLoginOnce sync.Once + peopleCodexLoginInitErr error + peopleCodexLogins *peopleCodexLogins // settingsConfigEditor is the persisted config transaction boundary. Tests // replace it to deterministically exercise post-publication error handling. settingsConfigEditor func(string, string, []config.Edit) (config.ConfigFile, error) @@ -422,6 +425,10 @@ type Server struct { // worker, which makes POST /people/{id}/brief/generate report unavailable. personBriefGeneratorMu sync.RWMutex personBriefGenerator PersonBriefGenerator + // peopleInferenceHTTPClient is the HTTP transport for synthetic provider + // checks. Nil uses the process default; tests route fixed vendor hosts to + // local HTTP fixtures without changing the configured egress policy. + peopleInferenceHTTPClient *http.Client // listenerBound is set true once StartOnListener binds a real listener // (the sole production serve path). It stays false for direct-handler unit // tests that drive s.Router() without starting a listener, leaving the diff --git a/internal/api/settings.go b/internal/api/settings.go index 1a6c5db1c..b1813a80c 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -387,6 +387,7 @@ func (s *Server) registerSettingsRoutes(api huma.API) { registerRawHumaRoute(api, patch, s.handlePatchSettings) s.registerProviderCredentialSettingsRoutes(api) s.registerPersonEnrichmentSettingsRoute(api) + s.registerPeopleInferenceSettingsRoute(api) } func addSettingsETagHeader(response *huma.Response) { diff --git a/internal/api/settings_credentials.go b/internal/api/settings_credentials.go index c933f39d2..2013831cc 100644 --- a/internal/api/settings_credentials.go +++ b/internal/api/settings_credentials.go @@ -61,7 +61,7 @@ func (s *Server) registerProviderCredentialSettingsRoutes(api huma.API) { } operation := rawAPIV1Operation(operationID, method, settingsProviderCredentialRoute, summary) operation.Parameters = append(operation.Parameters, - &huma.Param{Name: "credential_id", In: "path", Required: true, Schema: &huma.Schema{Type: huma.TypeString}}, + &huma.Param{Name: "credential_id", In: pathKey, Required: true, Schema: &huma.Schema{Type: huma.TypeString}}, &huma.Param{Name: ifMatchHeaderName, In: headerParamLocation, Required: true, Description: "Strong ETag for the provider credential store", Schema: &huma.Schema{Type: huma.TypeString}}, ) diff --git a/internal/api/settings_enrichment.go b/internal/api/settings_enrichment.go index c74bbcaa0..adc5f7f76 100644 --- a/internal/api/settings_enrichment.go +++ b/internal/api/settings_enrichment.go @@ -72,7 +72,7 @@ func (s *Server) registerPersonEnrichmentSettingsRoute(api huma.API) { operation := rawAPIV1Operation("putSettingsPersonEnrichmentProvider", http.MethodPut, settingsPersonEnrichmentProviderRoute, "Create or update one named person-enrichment provider") operation.Parameters = append(operation.Parameters, - &huma.Param{Name: "name", In: "path", Required: true, Schema: &huma.Schema{Type: huma.TypeString}}, + &huma.Param{Name: nameKey, In: pathKey, Required: true, Schema: &huma.Schema{Type: huma.TypeString}}, &huma.Param{Name: ifMatchHeaderName, In: headerParamLocation, Required: true, Description: "Strong config ETag returned by the latest settings read", Schema: &huma.Schema{Type: huma.TypeString}}, ) diff --git a/internal/api/settings_people_codex_login.go b/internal/api/settings_people_codex_login.go new file mode 100644 index 000000000..fe86b9a26 --- /dev/null +++ b/internal/api/settings_people_codex_login.go @@ -0,0 +1,342 @@ +package api + +import ( + "context" + "errors" + "net/http" + "os" + "path/filepath" + "slices" + "time" + + "go.kenn.io/msgvault/internal/config" + "go.kenn.io/msgvault/internal/peoplesweep" + "go.kenn.io/msgvault/internal/personenrollment" +) + +func (s *Server) codexLoginManager() (*peopleCodexLogins, error) { + s.peopleCodexLoginOnce.Do(func() { + if s.peopleCodexLogins != nil { + return + } + if !peoplesweep.CodexReleaseAvailable() { + s.peopleCodexLoginInitErr = errors.New("codex sign-in is unavailable until an inference build is approved") + return + } + if s.cfg == nil { + s.peopleCodexLoginInitErr = errors.New("people provider configuration is unavailable") + return + } + dataDir := s.cfg.Data.DataDir + tokensDir := s.cfg.TokensDir() + for index, directory := range []string{dataDir, tokensDir, filepath.Join(tokensDir, "people-codex")} { + if err := os.Mkdir(directory, 0o700); err != nil && !errors.Is(err, os.ErrExist) { + s.peopleCodexLoginInitErr = err + return + } + info, err := os.Lstat(directory) + if err != nil || !info.IsDir() || (index > 0 && info.Mode().Perm()&0o077 != 0) { + s.peopleCodexLoginInitErr = errors.New("codex auth directory must be private") + return + } + } + client, err := peoplesweep.NewCodexEnrollmentClient("codex", filepath.Join(tokensDir, "people-codex"), + peoplesweep.EnrollmentDraftLifetime) + if err != nil { + s.peopleCodexLoginInitErr = err + return + } + s.peopleCodexLogins = newPeopleCodexLogins(client, time.Now) + }) + if s.peopleCodexLoginInitErr != nil { + return nil, s.peopleCodexLoginInitErr + } + return s.peopleCodexLogins, nil +} + +// A new device login can replace the account behind an unchanged profile +// fingerprint. Remove prior authority before the auth file can change. +func (s *Server) revokePriorCodexEnrollmentAuthority(ctx context.Context) error { + _, configured, err := s.readPersistedSettings() + if err != nil { + return err + } + fingerprints := make(map[string]struct{}) + for name, provider := range configured.People.Sweep.Providers { + if provider.Protocol != peoplesweep.ProtocolCodexAppServer { + continue + } + candidate := configured.People.Sweep + candidate.Enabled = true + candidate.Provider = peoplesweep.ProviderSelection{Name: name} + profile, err := candidate.Profile() + if err != nil { + return err + } + fingerprints[profile.Fingerprint] = struct{}{} + } + if s.cfg.People.Sweep.Enabled { + name := s.cfg.People.Sweep.Provider.Name + if s.cfg.People.Sweep.Providers[name].Protocol == peoplesweep.ProtocolCodexAppServer { + profile, err := s.cfg.People.Sweep.Profile() + if err != nil { + return err + } + fingerprints[profile.Fingerprint] = struct{}{} + } + } + if len(fingerprints) == 0 { + return nil + } + st, ok := s.store.(peopleInferenceCredentialAuthorityStore) + if !ok { + return errors.New("people inference authority store is unavailable") + } + for fingerprint := range fingerprints { + if _, err := st.RevokePersonInferenceConsent(ctx, fingerprint, "web"); err != nil { + return err + } + if _, err := st.InvalidatePersonInferenceCheck(ctx, fingerprint); err != nil { + return err + } + } + return nil +} + +func (s *Server) peopleCodexLoginOwner(w http.ResponseWriter, r *http.Request) (string, bool) { + auth := s.requestAuthentication(r) + switch auth.Mode { + case AuthModeSession: + if auth.SessionID != "" { + return "session:" + auth.SessionID, true + } + case AuthModeAPIKey: + return "owner-api-key", true + case AuthModeLoopback: + return "loopback", true + case AuthModeRequired, AuthModeDelegated: + } + writeError(w, http.StatusForbidden, "codex_login_forbidden", "Codex sign-in requires an owner session") + return "", false +} + +func (s *Server) handleStartPeopleCodexLogin(w http.ResponseWriter, r *http.Request) { + owner, ok := s.peopleCodexLoginOwner(w, r) + if !ok { + return + } + logins, err := s.codexLoginManager() + if err != nil { + writeError(w, http.StatusServiceUnavailable, "codex_unavailable", "Codex device login is unavailable on this daemon") + return + } + var request PeopleCodexLoginRequest + if !decodeStrictSettingsJSON(w, r, &request) { + return + } + draft, err := logins.Start(owner, request.Name, func() error { + if s.operationGate != nil { + done, ok := beginGateWorkBounded(r.Context(), s.operationGate, "Codex enrollment") + if !ok { + return errors.New("archive is busy") + } + defer done() + } + _, configured, err := s.readPersistedSettings() + if err != nil { + return err + } + if _, exists := configured.People.Sweep.Providers[request.Name]; exists { + return personenrollment.ErrProfileExists + } + return s.revokePriorCodexEnrollmentAuthority(r.Context()) + }) + if err != nil { + switch { + case errors.Is(err, personenrollment.ErrProfileExists): + writeError(w, http.StatusConflict, "provider_exists", "People inference provider already exists; enrollment requires a new profile name") + case errors.Is(err, errPeopleCodexLoginBusy), errors.Is(err, peoplesweep.ErrEnrollmentDraftActive): + writeError(w, http.StatusConflict, "codex_login_active", "A Codex device login is already active") + case errors.Is(err, peoplesweep.ErrEnrollmentDraftInvalid): + writeError(w, http.StatusBadRequest, "invalid_codex_login", "Codex login request is invalid") + case errors.Is(err, errPeopleCodexLoginPreparation): + writeError(w, http.StatusServiceUnavailable, "codex_authority_unavailable", "Could not revoke prior Codex authority before sign-in") + default: + writeError(w, http.StatusBadRequest, "invalid_provider_name", "Codex profile name is invalid") + } + return + } + ticker := time.NewTicker(25 * time.Millisecond) + defer ticker.Stop() + wait := time.NewTimer(15 * time.Second) + defer wait.Stop() + for { + session, err := logins.Get(owner, draft.ID) + if err != nil { + writeError(w, http.StatusBadGateway, "codex_login_failed", "Codex device login could not start") + return + } + if session.login.UserCode != "" { + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, PeopleCodexLoginResponse{ + SessionID: draft.ID, VerificationURL: session.login.VerificationURL, + UserCode: session.login.UserCode, LocalDeadline: draft.ExpiresAt, + }) + return + } + if session.state == "failed" { + _ = logins.Cancel(owner, draft.ID) + writeError(w, http.StatusBadGateway, "codex_login_failed", "Codex device login could not start") + return + } + select { + case <-ticker.C: + case <-wait.C: + _ = logins.Cancel(owner, draft.ID) + writeError(w, http.StatusGatewayTimeout, "codex_login_timeout", "Codex device code was not available in time") + return + case <-r.Context().Done(): + _ = logins.Cancel(owner, draft.ID) + return + } + } +} + +func (s *Server) peopleCodexLoginForRequest(w http.ResponseWriter, r *http.Request) (string, peopleCodexLoginSession, bool) { + owner, ok := s.peopleCodexLoginOwner(w, r) + if !ok { + return "", peopleCodexLoginSession{}, false + } + logins, err := s.codexLoginManager() + if err != nil { + writeError(w, http.StatusServiceUnavailable, "codex_unavailable", "Codex device login is unavailable on this daemon") + return "", peopleCodexLoginSession{}, false + } + session, err := logins.Get(owner, r.PathValue("id")) + if err != nil { + writeError(w, http.StatusNotFound, "codex_login_not_found", "Codex device login was not found") + return "", peopleCodexLoginSession{}, false + } + return owner, session, true +} + +func (s *Server) handleGetPeopleCodexLogin(w http.ResponseWriter, r *http.Request) { + _, session, ok := s.peopleCodexLoginForRequest(w, r) + if !ok { + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, PeopleCodexLoginStatusResponse{State: session.state}) +} + +func (s *Server) handleCancelPeopleCodexLogin(w http.ResponseWriter, r *http.Request) { + owner, _, ok := s.peopleCodexLoginForRequest(w, r) + if !ok { + return + } + if err := s.peopleCodexLogins.Cancel(owner, r.PathValue("id")); err != nil { + if errors.Is(err, errPeopleCodexLoginCompleted) { + writeError(w, http.StatusConflict, "codex_login_completed", "Codex sign-in completed before cancellation") + } else { + writeError(w, http.StatusNotFound, "codex_login_not_found", "Codex device login was not found") + } + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, PeopleCodexLoginStatusResponse{State: "cancelled"}) +} + +func (s *Server) handleGetPeopleCodexModels(w http.ResponseWriter, r *http.Request) { + _, session, ok := s.peopleCodexLoginForRequest(w, r) + if !ok { + return + } + if session.state != "complete" { + writeError(w, http.StatusConflict, "codex_login_incomplete", "Complete Codex sign-in before listing models") + return + } + models, err := s.peopleCodexLogins.client.ListModels(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "codex_models_failed", "Could not list models for the signed-in Codex account") + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, PeopleCodexModelsResponse{Models: models}) +} + +func (s *Server) handlePutPeopleCodexProfile(w http.ResponseWriter, r *http.Request) { + ifMatch, ok := requiredSingleIfMatch(w, r) + if !ok { + return + } + owner, session, ok := s.peopleCodexLoginForRequest(w, r) + if !ok { + return + } + if session.state != "complete" { + writeError(w, http.StatusConflict, "codex_login_incomplete", "Complete Codex sign-in before creating a profile") + return + } + var request PeopleCodexProfileRequest + if !decodeStrictSettingsJSON(w, r, &request) { + return + } + models, err := s.peopleCodexLogins.client.ListModels(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "codex_models_failed", "Could not verify models for the signed-in Codex account") + return + } + validModel := false + for _, model := range models { + if model.ID == request.Model && slices.Contains(model.SupportedEfforts, request.ReasoningEffort) { + validModel = true + break + } + } + if !validModel { + writeError(w, http.StatusUnprocessableEntity, "codex_model_unavailable", "Model or reasoning effort is unavailable for the signed-in Codex account") + return + } + provider := peoplesweep.ProviderConfig{ + Protocol: peoplesweep.ProtocolCodexAppServer, Model: request.Model, + ReasoningEffort: request.ReasoningEffort, Executable: "codex", + ExecutionBoundary: peoplesweep.CodexExecutionBoundaryV1, + Auth: peoplesweep.AuthNone, Credential: peoplesweep.CredentialNone, + OutputMode: peoplesweep.OutputModeNativeJSONSchema, + RetentionPosture: request.RetentionPosture, TrainingPosture: request.TrainingPosture, + SourceSince: request.SourceSince, SourceUntil: request.SourceUntil, + AllowSensitive: request.AllowSensitive, + } + for _, source := range request.AllowedSources { + provider.AllowedSources = append(provider.AllowedSources, peoplesweep.SourceClass(source)) + } + service := personenrollment.NewService(s.cfg.ConfigFilePath(), nil) + if _, err := service.CreateProfile(ifMatch, session.name, provider); err != nil { + switch { + case errors.Is(err, config.ErrConfigConflict): + writeError(w, http.StatusPreconditionFailed, "settings_conflict", "The config file changed; reload settings and retry") + case errors.Is(err, personenrollment.ErrProfileExists): + writeError(w, http.StatusConflict, "provider_exists", "People inference provider already exists") + case errors.Is(err, personenrollment.ErrInvalidProfile): + writeError(w, http.StatusUnprocessableEntity, "invalid_provider", "Codex provider settings are invalid") + default: + s.writeSettingsConfigError(w, err) + } + return + } + _ = s.peopleCodexLogins.Cancel(owner, session.draft.ID) + s.settingsPendingRestart.Store(true) + snapshot, configured, err := s.readPersistedSettings() + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read settings") + return + } + response, err := s.buildPeopleInferenceSettingsResponse(r.Context(), configured) + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read people provider status") + return + } + w.Header().Set(etagHeaderName, snapshot.ETag) + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, response) +} diff --git a/internal/api/settings_people_inference.go b/internal/api/settings_people_inference.go new file mode 100644 index 000000000..b67682c00 --- /dev/null +++ b/internal/api/settings_people_inference.go @@ -0,0 +1,1053 @@ +package api + +import ( + "context" + "errors" + "net/http" + "os" + "path/filepath" + "reflect" + "slices" + "time" + + "github.com/danielgtaylor/huma/v2" + "go.kenn.io/msgvault/internal/config" + "go.kenn.io/msgvault/internal/peoplesweep" + "go.kenn.io/msgvault/internal/personenrollment" + "go.kenn.io/msgvault/internal/store" +) + +// PeopleInferenceProfileSetting exposes policy fields used during enrollment. +// Credential values and Codex authentication state are intentionally absent. +type PeopleInferenceProfileSetting struct { + Name string `json:"name"` + Selected bool `json:"selected"` + PresetID string `json:"preset_id,omitempty"` + Protocol string `json:"protocol"` + Endpoint string `json:"endpoint,omitempty"` + Model string `json:"model"` + CredentialSource string `json:"credential_source"` + CredentialEnv string `json:"credential_env,omitempty"` + CredentialConfigured bool `json:"credential_configured"` + CredentialRevision string `json:"credential_revision,omitempty"` + Checked bool `json:"checked"` + ConsentActive bool `json:"consent_active"` + OutputMode string `json:"output_mode"` + RetentionPosture string `json:"retention_posture"` + TrainingPosture string `json:"training_posture"` + AllowedSources []string `json:"allowed_sources"` + SourceSince string `json:"source_since"` + SourceUntil string `json:"source_until,omitempty"` + AllowSensitive bool `json:"allow_sensitive"` + Fingerprint string `json:"fingerprint,omitempty"` +} + +// PeopleInferenceSettingsResponse distinguishes disk configuration from the +// policy the daemon loaded at startup. A saved change takes effect on restart. +type PeopleInferenceSettingsResponse struct { + StoredCredentialsSupported bool `json:"stored_credentials_supported"` + Profiles []PeopleInferenceProfileSetting `json:"profiles"` + ConfiguredName string `json:"configured_name,omitempty"` + ConfiguredEnabled bool `json:"configured_enabled"` + ConfiguredFingerprint string `json:"configured_fingerprint,omitempty"` + RunningName string `json:"running_name,omitempty"` + RunningEnabled bool `json:"running_enabled"` + RunningFingerprint string `json:"running_fingerprint,omitempty"` + PendingRestart bool `json:"pending_restart"` +} + +type PeopleInferenceSelectionRequest struct { + Name string `json:"name" minLength:"1"` +} + +// PeopleInferencePresetCreateRequest intentionally has no endpoint or key +// field. The daemon binds the selected vendor destination before a separate +// credential write can occur. +type PeopleInferencePresetCreateRequest struct { + PresetID string `json:"preset_id" enum:"openai,openrouter,venice"` + Model string `json:"model" minLength:"1"` + RetentionPosture string `json:"retention_posture" minLength:"1"` + TrainingPosture string `json:"training_posture" minLength:"1"` + AllowedSources []string `json:"allowed_sources" minItems:"1"` + SourceSince string `json:"source_since"` + SourceUntil string `json:"source_until,omitempty"` + AllowSensitive bool `json:"allow_sensitive"` +} + +type PeopleInferenceKeyWriteRequest struct { + Value string `json:"value" minLength:"1"` +} + +type PeopleInferenceCheckResponse struct { + OK bool `json:"ok"` + Fingerprint string `json:"fingerprint"` + Model string `json:"model"` + Usage peoplesweep.TokenUsage `json:"usage"` +} + +type PeopleInferenceConsentRequest struct { + Fingerprint string `json:"fingerprint"` + Confirmed bool `json:"confirmed"` +} + +type PeopleCodexLoginRequest struct { + Name string `json:"name" minLength:"1"` +} + +type PeopleCodexLoginResponse struct { + SessionID string `json:"session_id"` + VerificationURL string `json:"verification_url"` + UserCode string `json:"user_code"` + LocalDeadline time.Time `json:"local_deadline"` +} + +type PeopleCodexLoginStatusResponse struct { + State string `json:"state"` +} + +type PeopleCodexModelsResponse struct { + Models []peoplesweep.CodexModel `json:"models"` +} + +type PeopleCodexProfileRequest struct { + Model string `json:"model" minLength:"1"` + ReasoningEffort string `json:"reasoning_effort" minLength:"1"` + RetentionPosture string `json:"retention_posture" minLength:"1"` + TrainingPosture string `json:"training_posture" minLength:"1"` + AllowedSources []string `json:"allowed_sources" minItems:"1"` + SourceSince string `json:"source_since"` + SourceUntil string `json:"source_until,omitempty"` + AllowSensitive bool `json:"allow_sensitive"` +} + +type peopleInferenceCheckStore interface { + peoplesweep.ProviderAuthority + EnsurePersonInferenceProfile(ctx context.Context, profile peoplesweep.ProviderProfile) (bool, error) + RecordPersonInferenceCheck(ctx context.Context, check store.PersonInferenceCheck) error +} + +type peopleInferenceConsentStore interface { + peoplesweep.ProviderAuthority + EnsurePersonInferenceProfile(ctx context.Context, profile peoplesweep.ProviderProfile) (bool, error) + GrantPersonInferenceConsent(ctx context.Context, fingerprint, actor string) (*store.PersonInferenceConsent, bool, error) +} + +type peopleInferenceCredentialAuthorityStore interface { + RevokePersonInferenceConsent(ctx context.Context, fingerprint, actor string) (bool, error) + InvalidatePersonInferenceCheck(ctx context.Context, fingerprint string) (bool, error) +} + +func (s *Server) registerPeopleInferenceSettingsRoute(api huma.API) { + operation := rawAPIV1Operation("getSettingsPeopleInference", http.MethodGet, + "/settings/people-inference", "Get people inference provider status") + operation.Responses = jsonResponsesFor[PeopleInferenceSettingsResponse](api) + addSettingsETagHeader(operation.Responses[httpStatusKey(http.StatusOK)]) + registerRawHumaRoute(api, operation, s.handleGetPeopleInferenceSettings) + + selectOperation := rawAPIV1Operation("selectSettingsPeopleInference", http.MethodPost, + "/settings/people-inference/select", "Select a checked and consented people inference provider") + selectOperation.Parameters = append(selectOperation.Parameters, + &huma.Param{Name: ifMatchHeaderName, In: headerParamLocation, Required: true, + Description: "Strong config ETag returned by the latest settings read", Schema: &huma.Schema{Type: huma.TypeString}}, + ) + selectOperation.RequestBody = jsonRequestBodyFor[PeopleInferenceSelectionRequest](api) + selectOperation.Responses = jsonResponsesFor[PeopleInferenceSettingsResponse](api) + for _, status := range []int{http.StatusBadRequest, http.StatusConflict, http.StatusPreconditionFailed, + http.StatusPreconditionRequired, http.StatusServiceUnavailable, http.StatusUnprocessableEntity, + http.StatusInternalServerError} { + selectOperation.Responses[httpStatusKey(status)] = errorResponseFor(api) + } + addSettingsETagHeader(selectOperation.Responses[httpStatusKey(http.StatusOK)]) + registerRawHumaRoute(api, selectOperation, s.handleSelectPeopleInferenceSettings) + + createOperation := rawAPIV1Operation("putSettingsPeopleInferencePreset", http.MethodPut, + "/settings/people-inference/providers/{name}", "Create a vendor-bound people inference provider") + createOperation.Parameters = append(createOperation.Parameters, + &huma.Param{Name: nameKey, In: pathKey, Required: true, Schema: &huma.Schema{Type: huma.TypeString}}, + &huma.Param{Name: ifMatchHeaderName, In: headerParamLocation, Required: true, + Description: "Strong config ETag returned by the latest settings read", Schema: &huma.Schema{Type: huma.TypeString}}, + ) + createOperation.RequestBody = jsonRequestBodyFor[PeopleInferencePresetCreateRequest](api) + createOperation.Responses = jsonResponsesFor[PeopleInferenceSettingsResponse](api) + for _, status := range []int{http.StatusBadRequest, http.StatusConflict, http.StatusPreconditionFailed, + http.StatusPreconditionRequired, http.StatusUnprocessableEntity, http.StatusServiceUnavailable, http.StatusInternalServerError} { + createOperation.Responses[httpStatusKey(status)] = errorResponseFor(api) + } + addSettingsETagHeader(createOperation.Responses[httpStatusKey(http.StatusOK)]) + registerRawHumaRoute(api, createOperation, s.handleCreatePeopleInferencePreset) + + keyOperation := rawAPIV1Operation("putSettingsPeopleInferenceKey", http.MethodPut, + "/settings/people-inference/providers/{name}/key", "Set a write-only people inference API key") + keyOperation.Parameters = append(keyOperation.Parameters, + &huma.Param{Name: nameKey, In: pathKey, Required: true, Schema: &huma.Schema{Type: huma.TypeString}}, + &huma.Param{Name: ifMatchHeaderName, In: headerParamLocation, Required: true, + Description: "Opaque revision for this people provider credential", Schema: &huma.Schema{Type: huma.TypeString}}, + ) + keyOperation.RequestBody = jsonRequestBodyFor[PeopleInferenceKeyWriteRequest](api) + keyOperation.Responses = jsonResponsesFor[PeopleInferenceSettingsResponse](api) + for _, status := range []int{http.StatusBadRequest, http.StatusConflict, http.StatusNotFound, + http.StatusPreconditionFailed, http.StatusPreconditionRequired, http.StatusServiceUnavailable, + http.StatusInternalServerError} { + keyOperation.Responses[httpStatusKey(status)] = errorResponseFor(api) + } + addSettingsETagHeader(keyOperation.Responses[httpStatusKey(http.StatusOK)]) + registerRawHumaRoute(api, keyOperation, s.handlePutPeopleInferenceKey) + + deleteKeyOperation := rawAPIV1Operation("deleteSettingsPeopleInferenceKey", http.MethodDelete, + "/settings/people-inference/providers/{name}/key", "Clear a stored people inference API key") + deleteKeyOperation.Parameters = append(deleteKeyOperation.Parameters, + &huma.Param{Name: nameKey, In: pathKey, Required: true, Schema: &huma.Schema{Type: huma.TypeString}}, + &huma.Param{Name: ifMatchHeaderName, In: headerParamLocation, Required: true, + Description: "Opaque revision for this people provider credential", Schema: &huma.Schema{Type: huma.TypeString}}, + ) + deleteKeyOperation.Responses = jsonResponsesFor[PeopleInferenceSettingsResponse](api) + for _, status := range []int{http.StatusBadRequest, http.StatusConflict, http.StatusNotFound, + http.StatusPreconditionFailed, http.StatusPreconditionRequired, http.StatusServiceUnavailable, + http.StatusInternalServerError} { + deleteKeyOperation.Responses[httpStatusKey(status)] = errorResponseFor(api) + } + addSettingsETagHeader(deleteKeyOperation.Responses[httpStatusKey(http.StatusOK)]) + registerRawHumaRoute(api, deleteKeyOperation, s.handleDeletePeopleInferenceKey) + + checkOperation := rawAPIV1Operation("checkSettingsPeopleInferenceProvider", http.MethodPost, + "/settings/people-inference/providers/{name}/check", "Run a synthetic people inference provider check") + checkOperation.Parameters = append(checkOperation.Parameters, + &huma.Param{Name: nameKey, In: pathKey, Required: true, Schema: &huma.Schema{Type: huma.TypeString}}, + &huma.Param{Name: ifMatchHeaderName, In: headerParamLocation, Required: true, + Description: "Strong config ETag returned by the latest settings read", Schema: &huma.Schema{Type: huma.TypeString}}, + ) + checkOperation.Responses = jsonResponsesFor[PeopleInferenceCheckResponse](api) + for _, status := range []int{http.StatusBadRequest, http.StatusConflict, http.StatusPreconditionFailed, + http.StatusPreconditionRequired, http.StatusBadGateway, http.StatusServiceUnavailable, + http.StatusInternalServerError} { + checkOperation.Responses[httpStatusKey(status)] = errorResponseFor(api) + } + registerRawHumaRoute(api, checkOperation, s.handleCheckPeopleInferenceProvider) + + consentOperation := rawAPIV1Operation("consentSettingsPeopleInferenceProvider", http.MethodPost, + "/settings/people-inference/providers/{name}/consent", "Grant exact people inference consent") + consentOperation.Parameters = append(consentOperation.Parameters, + &huma.Param{Name: nameKey, In: pathKey, Required: true, Schema: &huma.Schema{Type: huma.TypeString}}, + &huma.Param{Name: ifMatchHeaderName, In: headerParamLocation, Required: true, + Description: "Strong config ETag returned by the latest settings read", Schema: &huma.Schema{Type: huma.TypeString}}, + ) + consentOperation.RequestBody = jsonRequestBodyFor[PeopleInferenceConsentRequest](api) + consentOperation.Responses = jsonResponsesFor[PeopleInferenceSettingsResponse](api) + for _, status := range []int{http.StatusBadRequest, http.StatusConflict, http.StatusPreconditionFailed, + http.StatusPreconditionRequired, http.StatusServiceUnavailable, http.StatusInternalServerError} { + consentOperation.Responses[httpStatusKey(status)] = errorResponseFor(api) + } + addSettingsETagHeader(consentOperation.Responses[httpStatusKey(http.StatusOK)]) + registerRawHumaRoute(api, consentOperation, s.handleConsentPeopleInferenceProvider) + + revokeOperation := rawAPIV1Operation("revokeSettingsPeopleInferenceProvider", http.MethodPost, + "/settings/people-inference/providers/{name}/revoke", "Revoke exact people inference consent") + revokeOperation.Parameters = append(revokeOperation.Parameters, + &huma.Param{Name: nameKey, In: pathKey, Required: true, Schema: &huma.Schema{Type: huma.TypeString}}, + &huma.Param{Name: ifMatchHeaderName, In: headerParamLocation, Required: true, + Description: "Strong config ETag returned by the latest settings read", Schema: &huma.Schema{Type: huma.TypeString}}, + ) + revokeOperation.Responses = jsonResponsesFor[PeopleInferenceSettingsResponse](api) + for _, status := range []int{http.StatusBadRequest, http.StatusConflict, http.StatusPreconditionFailed, + http.StatusPreconditionRequired, http.StatusServiceUnavailable, http.StatusInternalServerError} { + revokeOperation.Responses[httpStatusKey(status)] = errorResponseFor(api) + } + addSettingsETagHeader(revokeOperation.Responses[httpStatusKey(http.StatusOK)]) + registerRawHumaRoute(api, revokeOperation, s.handleRevokePeopleInferenceProvider) + + disableOperation := rawAPIV1Operation("disableSettingsPeopleInference", http.MethodPost, + "/settings/people-inference/disable", "Disable people inference and revoke active consent") + disableOperation.Parameters = append(disableOperation.Parameters, + &huma.Param{Name: ifMatchHeaderName, In: headerParamLocation, Required: true, + Description: "Strong config ETag returned by the latest settings read", Schema: &huma.Schema{Type: huma.TypeString}}, + ) + disableOperation.Responses = jsonResponsesFor[PeopleInferenceSettingsResponse](api) + for _, status := range []int{http.StatusBadRequest, http.StatusConflict, http.StatusPreconditionFailed, + http.StatusPreconditionRequired, http.StatusServiceUnavailable, http.StatusInternalServerError} { + disableOperation.Responses[httpStatusKey(status)] = errorResponseFor(api) + } + addSettingsETagHeader(disableOperation.Responses[httpStatusKey(http.StatusOK)]) + registerRawHumaRoute(api, disableOperation, s.handleDisablePeopleInference) + + removeOperation := rawAPIV1Operation("deleteSettingsPeopleInferenceProvider", http.MethodDelete, + "/settings/people-inference/providers/{name}", "Remove a people inference provider profile") + removeOperation.Parameters = append(removeOperation.Parameters, + &huma.Param{Name: nameKey, In: pathKey, Required: true, Schema: &huma.Schema{Type: huma.TypeString}}, + &huma.Param{Name: ifMatchHeaderName, In: headerParamLocation, Required: true, + Description: "Strong config ETag returned by the latest settings read", Schema: &huma.Schema{Type: huma.TypeString}}, + ) + removeOperation.Responses = jsonResponsesFor[PeopleInferenceSettingsResponse](api) + for _, status := range []int{http.StatusBadRequest, http.StatusConflict, http.StatusNotFound, + http.StatusPreconditionFailed, http.StatusPreconditionRequired, http.StatusServiceUnavailable, + http.StatusInternalServerError} { + removeOperation.Responses[httpStatusKey(status)] = errorResponseFor(api) + } + addSettingsETagHeader(removeOperation.Responses[httpStatusKey(http.StatusOK)]) + registerRawHumaRoute(api, removeOperation, s.handleRemovePeopleInferenceProvider) + + loginOperation := rawAPIV1Operation("startSettingsPeopleCodexLogin", http.MethodPost, + "/settings/people-inference/codex/login", "Start a private Codex device login") + loginOperation.RequestBody = jsonRequestBodyFor[PeopleCodexLoginRequest](api) + loginOperation.Responses = jsonResponsesFor[PeopleCodexLoginResponse](api) + for _, status := range []int{http.StatusBadRequest, http.StatusConflict, http.StatusForbidden, + http.StatusServiceUnavailable, http.StatusBadGateway, http.StatusGatewayTimeout} { + loginOperation.Responses[httpStatusKey(status)] = errorResponseFor(api) + } + registerRawHumaRoute(api, loginOperation, s.handleStartPeopleCodexLogin) + + for _, route := range []struct { + method, id, path, description string + handler http.HandlerFunc + }{ + {http.MethodGet, "getSettingsPeopleCodexLogin", "/settings/people-inference/codex/login/{id}", "Get Codex device login status", s.handleGetPeopleCodexLogin}, + {http.MethodDelete, "cancelSettingsPeopleCodexLogin", "/settings/people-inference/codex/login/{id}", "Cancel Codex device login", s.handleCancelPeopleCodexLogin}, + {http.MethodGet, "getSettingsPeopleCodexModels", "/settings/people-inference/codex/login/{id}/models", "List models for completed Codex login", s.handleGetPeopleCodexModels}, + } { + operation := rawAPIV1Operation(route.id, route.method, route.path, route.description) + operation.Parameters = append(operation.Parameters, + &huma.Param{Name: "id", In: pathKey, Required: true, Schema: &huma.Schema{Type: huma.TypeString}}, + ) + if route.method == http.MethodDelete { + operation.Responses = jsonResponsesFor[PeopleCodexLoginStatusResponse](api) + } else if route.id == "getSettingsPeopleCodexModels" { + operation.Responses = jsonResponsesFor[PeopleCodexModelsResponse](api) + } else { + operation.Responses = jsonResponsesFor[PeopleCodexLoginStatusResponse](api) + } + for _, status := range []int{http.StatusConflict, http.StatusForbidden, http.StatusNotFound, + http.StatusServiceUnavailable, http.StatusBadGateway} { + operation.Responses[httpStatusKey(status)] = errorResponseFor(api) + } + registerRawHumaRoute(api, operation, route.handler) + } + + codexProfileOperation := rawAPIV1Operation("putSettingsPeopleCodexProfile", http.MethodPut, + "/settings/people-inference/codex/login/{id}/profile", "Create a Codex profile from completed device login") + codexProfileOperation.Parameters = append(codexProfileOperation.Parameters, + &huma.Param{Name: "id", In: pathKey, Required: true, Schema: &huma.Schema{Type: huma.TypeString}}, + &huma.Param{Name: ifMatchHeaderName, In: headerParamLocation, Required: true, + Description: "Strong config ETag returned by the latest settings read", Schema: &huma.Schema{Type: huma.TypeString}}, + ) + codexProfileOperation.RequestBody = jsonRequestBodyFor[PeopleCodexProfileRequest](api) + codexProfileOperation.Responses = jsonResponsesFor[PeopleInferenceSettingsResponse](api) + for _, status := range []int{http.StatusBadRequest, http.StatusConflict, http.StatusForbidden, + http.StatusNotFound, http.StatusPreconditionFailed, http.StatusPreconditionRequired, + http.StatusServiceUnavailable, http.StatusBadGateway, http.StatusUnprocessableEntity, + http.StatusInternalServerError} { + codexProfileOperation.Responses[httpStatusKey(status)] = errorResponseFor(api) + } + addSettingsETagHeader(codexProfileOperation.Responses[httpStatusKey(http.StatusOK)]) + registerRawHumaRoute(api, codexProfileOperation, s.handlePutPeopleCodexProfile) +} + +func (s *Server) handleGetPeopleInferenceSettings(w http.ResponseWriter, r *http.Request) { + snapshot, configured, err := s.readPersistedSettings() + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read settings") + return + } + response, err := s.buildPeopleInferenceSettingsResponse(r.Context(), configured) + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read people provider status") + return + } + w.Header().Set(etagHeaderName, snapshot.ETag) + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, response) +} + +func (s *Server) handleSelectPeopleInferenceSettings(w http.ResponseWriter, r *http.Request) { + ifMatch, ok := requiredSingleIfMatch(w, r) + if !ok { + return + } + var request PeopleInferenceSelectionRequest + if !decodeStrictSettingsJSON(w, r, &request) { + return + } + if err := peoplesweep.ValidateProviderProfileName(request.Name); err != nil { + writeError(w, http.StatusBadRequest, "invalid_provider", "People inference provider name is invalid") + return + } + st, ok := s.store.(personenrollment.CheckConsentStore) + if !ok { + writeError(w, http.StatusServiceUnavailable, "people_inference_unavailable", "People inference store is unavailable") + return + } + service := personenrollment.NewService(s.cfg.ConfigFilePath(), st) + if _, err := service.SelectProfile(r.Context(), ifMatch, request.Name); err != nil { + switch { + case errors.Is(err, config.ErrConfigConflict): + writeError(w, http.StatusPreconditionFailed, "settings_conflict", "The config file changed; reload settings and retry") + case errors.Is(err, personenrollment.ErrCheckRequired): + writeError(w, http.StatusConflict, "check_required", "Run an exact synthetic check before selecting this provider") + case errors.Is(err, personenrollment.ErrConsentRequired): + writeError(w, http.StatusConflict, "consent_required", "Grant exact people inference consent before selecting this provider") + case errors.Is(err, personenrollment.ErrProfileMissing): + writeError(w, http.StatusBadRequest, "provider_not_found", "People inference provider was not found") + default: + s.writeSettingsConfigError(w, err) + } + return + } + snapshot, configured, err := s.readPersistedSettings() + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read settings") + return + } + s.settingsPendingRestart.Store(true) + w.Header().Set(etagHeaderName, snapshot.ETag) + w.Header().Set("Cache-Control", "no-store") + response, err := s.buildPeopleInferenceSettingsResponse(r.Context(), configured) + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read people provider status") + return + } + writeJSON(w, http.StatusOK, response) +} + +func (s *Server) handleCreatePeopleInferencePreset(w http.ResponseWriter, r *http.Request) { + ifMatch, ok := requiredSingleIfMatch(w, r) + if !ok { + return + } + var request PeopleInferencePresetCreateRequest + if !decodeStrictSettingsJSON(w, r, &request) { + return + } + if !peoplesweep.StoredCredentialsSupported() { + writeError(w, http.StatusServiceUnavailable, "credential_store_unsupported", + "Stored provider keys are unavailable on this platform; configure an environment credential with msgvault person provider add and --credential-env on the daemon host") + return + } + if err := peoplesweep.ValidateProviderProfileName(r.PathValue("name")); err != nil { + writeError(w, http.StatusBadRequest, "invalid_provider_name", "People inference provider name is invalid") + return + } + provider, err := peoplesweep.PresetProviderConfig(request.PresetID, request.Model) + if err != nil { + writeError(w, http.StatusUnprocessableEntity, "invalid_provider", "People inference provider preset is invalid") + return + } + provider.RetentionPosture = request.RetentionPosture + provider.TrainingPosture = request.TrainingPosture + provider.SourceSince = request.SourceSince + provider.SourceUntil = request.SourceUntil + provider.AllowSensitive = request.AllowSensitive + for _, source := range request.AllowedSources { + provider.AllowedSources = append(provider.AllowedSources, peoplesweep.SourceClass(source)) + } + service := personenrollment.NewService(s.cfg.ConfigFilePath(), nil) + if _, err := service.CreateProfile(ifMatch, r.PathValue("name"), provider); err != nil { + switch { + case errors.Is(err, config.ErrConfigConflict): + writeError(w, http.StatusPreconditionFailed, "settings_conflict", "The config file changed; reload settings and retry") + case errors.Is(err, personenrollment.ErrProfileExists): + writeError(w, http.StatusConflict, "provider_exists", "People inference provider already exists") + case errors.Is(err, personenrollment.ErrInvalidProfile): + writeError(w, http.StatusUnprocessableEntity, "invalid_provider", "People inference provider settings are invalid") + default: + s.writeSettingsConfigError(w, err) + } + return + } + snapshot, configured, err := s.readPersistedSettings() + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read settings") + return + } + s.settingsPendingRestart.Store(true) + w.Header().Set(etagHeaderName, snapshot.ETag) + w.Header().Set("Cache-Control", "no-store") + response, err := s.buildPeopleInferenceSettingsResponse(r.Context(), configured) + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read people provider status") + return + } + writeJSON(w, http.StatusOK, response) +} + +func (s *Server) handlePutPeopleInferenceKey(w http.ResponseWriter, r *http.Request) { + ifMatch, ok := requiredSingleIfMatch(w, r) + if !ok { + return + } + target, ok := s.peopleInferenceKeyTarget(w, r, ifMatch, false) + if !ok { + return + } + var request PeopleInferenceKeyWriteRequest + if !decodeStrictSettingsJSON(w, r, &request) { + return + } + if request.Value == "" { + writeError(w, http.StatusBadRequest, "invalid_credential", "An API key is required") + return + } + if err := target.revokeAuthority(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "authority_revoke_failed", "Could not revoke prior provider consent and check") + return + } + if _, err := target.credentials.SaveIfRevision(target.name, peoplesweep.NewCredential(peoplesweep.AuthBearer, request.Value), ifMatch); err != nil { + if errors.Is(err, peoplesweep.ErrCredentialRevisionConflict) { + writeError(w, http.StatusPreconditionFailed, "credential_conflict", "People provider credential changed; reload settings") + } else { + writeError(w, http.StatusInternalServerError, "credential_store_unavailable", "Could not save people provider credential") + } + return + } + snapshot, latest, err := s.readPersistedSettings() + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read settings") + return + } + response, err := s.buildPeopleInferenceSettingsResponse(r.Context(), latest) + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read people provider status") + return + } + w.Header().Set(etagHeaderName, snapshot.ETag) + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, response) +} + +func (s *Server) handleDeletePeopleInferenceKey(w http.ResponseWriter, r *http.Request) { + ifMatch, ok := requiredSingleIfMatch(w, r) + if !ok { + return + } + target, ok := s.peopleInferenceKeyTarget(w, r, ifMatch, true) + if !ok { + return + } + if err := target.revokeAuthority(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "authority_revoke_failed", "Could not revoke prior provider consent and check") + return + } + if _, err := target.credentials.DeleteIfRevision(target.name, ifMatch); err != nil { + switch { + case errors.Is(err, peoplesweep.ErrCredentialRevisionConflict): + writeError(w, http.StatusPreconditionFailed, "credential_conflict", "People provider credential changed; reload settings") + case errors.Is(err, peoplesweep.ErrCredentialNotFound): + writeError(w, http.StatusNotFound, "credential_not_found", "No stored people provider credential was found") + default: + writeError(w, http.StatusInternalServerError, "credential_store_unavailable", "Could not clear people provider credential") + } + return + } + snapshot, latest, err := s.readPersistedSettings() + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read settings") + return + } + response, err := s.buildPeopleInferenceSettingsResponse(r.Context(), latest) + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read people provider status") + return + } + w.Header().Set(etagHeaderName, snapshot.ETag) + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, response) +} + +type peopleInferenceKeyTarget struct { + name string + fingerprints []string + store peopleInferenceCredentialAuthorityStore + credentials *peoplesweep.FileCredentialStore +} + +// A saved policy may differ from the daemon's policy while restart is pending. +// Both use the same profile key, so replacing it invalidates both authorities. +func (target peopleInferenceKeyTarget) revokeAuthority(ctx context.Context) error { + for _, fingerprint := range target.fingerprints { + if _, err := target.store.RevokePersonInferenceConsent(ctx, fingerprint, "web"); err != nil { + return err + } + if _, err := target.store.InvalidatePersonInferenceCheck(ctx, fingerprint); err != nil { + return err + } + } + return nil +} + +// peopleInferenceKeyTarget validates the trusted destination and revision +// before a write handler reads any secret-bearing request body. +func (s *Server) peopleInferenceKeyTarget( + w http.ResponseWriter, r *http.Request, ifMatch string, requireExisting bool, +) (peopleInferenceKeyTarget, bool) { + name := r.PathValue("name") + if err := peoplesweep.ValidateProviderProfileName(name); err != nil { + writeError(w, http.StatusBadRequest, "invalid_provider_name", "People inference provider name is invalid") + return peopleInferenceKeyTarget{}, false + } + _, configured, err := s.readPersistedSettings() + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read settings") + return peopleInferenceKeyTarget{}, false + } + provider, exists := configured.People.Sweep.Providers[name] + if !exists || provider.Credential != peoplesweep.CredentialStored || provider.PresetID == "" { + writeError(w, http.StatusNotFound, "provider_not_found", "A stored-key preset provider was not found") + return peopleInferenceKeyTarget{}, false + } + bound, err := peoplesweep.PresetProviderConfig(provider.PresetID, provider.Model) + if err != nil || provider.Endpoint != bound.Endpoint || provider.Protocol != bound.Protocol || provider.Auth != bound.Auth { + writeError(w, http.StatusConflict, "provider_binding_changed", "Provider destination changed; reload settings") + return peopleInferenceKeyTarget{}, false + } + profileConfig := configured.People.Sweep + profileConfig.Enabled = true + profileConfig.Provider = peoplesweep.ProviderSelection{Name: name} + profile, err := profileConfig.Profile() + if err != nil { + writeError(w, http.StatusConflict, "provider_invalid", "Provider policy is invalid") + return peopleInferenceKeyTarget{}, false + } + st, ok := s.store.(peopleInferenceCredentialAuthorityStore) + if !ok { + writeError(w, http.StatusServiceUnavailable, "people_inference_unavailable", "People inference store is unavailable") + return peopleInferenceKeyTarget{}, false + } + credentials := peoplesweep.NewFileCredentialStore(configured.TokensDir()) + current, present, err := credentials.Revision(name) + if err != nil { + writeError(w, http.StatusInternalServerError, "credential_store_unavailable", "People provider credential store is unavailable") + return peopleInferenceKeyTarget{}, false + } + if current != ifMatch { + writeError(w, http.StatusPreconditionFailed, "credential_conflict", "People provider credential changed; reload settings") + return peopleInferenceKeyTarget{}, false + } + if requireExisting && !present { + writeError(w, http.StatusNotFound, "credential_not_found", "No stored people provider credential was found") + return peopleInferenceKeyTarget{}, false + } + fingerprints := []string{profile.Fingerprint} + if s.cfg.People.Sweep.Enabled && s.cfg.People.Sweep.Provider.Name == name { + running, err := s.cfg.People.Sweep.Profile() + if err != nil { + writeError(w, http.StatusInternalServerError, "running_provider_invalid", "Running people provider policy is invalid") + return peopleInferenceKeyTarget{}, false + } + if running.Fingerprint != profile.Fingerprint { + fingerprints = append(fingerprints, running.Fingerprint) + } + } + return peopleInferenceKeyTarget{name: name, fingerprints: fingerprints, store: st, credentials: credentials}, true +} + +func (s *Server) peopleInferenceProfileForRequest( + w http.ResponseWriter, r *http.Request, ifMatch string, +) (config.ConfigFile, *config.Config, peoplesweep.Config, peoplesweep.ProviderProfile, bool) { + name := r.PathValue("name") + if err := peoplesweep.ValidateProviderProfileName(name); err != nil { + writeError(w, http.StatusBadRequest, "invalid_provider_name", "People inference provider name is invalid") + return config.ConfigFile{}, nil, peoplesweep.Config{}, peoplesweep.ProviderProfile{}, false + } + snapshot, configured, err := s.readPersistedSettings() + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read settings") + return config.ConfigFile{}, nil, peoplesweep.Config{}, peoplesweep.ProviderProfile{}, false + } + if snapshot.ETag != ifMatch { + writeError(w, http.StatusPreconditionFailed, "settings_conflict", "The config file changed; reload settings and retry") + return config.ConfigFile{}, nil, peoplesweep.Config{}, peoplesweep.ProviderProfile{}, false + } + if _, exists := configured.People.Sweep.Providers[name]; !exists { + writeError(w, http.StatusBadRequest, "provider_not_found", "People inference provider was not found") + return config.ConfigFile{}, nil, peoplesweep.Config{}, peoplesweep.ProviderProfile{}, false + } + selected := configured.People.Sweep + selected.Enabled = true + selected.Provider = peoplesweep.ProviderSelection{Name: name} + profile, err := selected.Profile() + if err != nil { + writeError(w, http.StatusConflict, "provider_invalid", "People inference provider policy is invalid") + return config.ConfigFile{}, nil, peoplesweep.Config{}, peoplesweep.ProviderProfile{}, false + } + return snapshot, configured, selected, profile, true +} + +func (s *Server) handleCheckPeopleInferenceProvider(w http.ResponseWriter, r *http.Request) { + ifMatch, ok := requiredSingleIfMatch(w, r) + if !ok { + return + } + _, configured, selected, profile, ok := s.peopleInferenceProfileForRequest(w, r, ifMatch) + if !ok { + return + } + if profile.Protocol == peoplesweep.ProtocolCodexAppServer && !peoplesweep.CodexReleaseAvailable() { + writeError(w, http.StatusServiceUnavailable, "codex_unavailable", "Codex inference is unavailable until an inference build is approved") + return + } + st, ok := s.store.(peopleInferenceCheckStore) + if !ok { + writeError(w, http.StatusServiceUnavailable, "people_inference_unavailable", "People inference store is unavailable") + return + } + client := s.peopleInferenceHTTPClient + if client == nil { + client = http.DefaultClient + } + registry, err := peoplesweep.NewDriverRegistryWithCodexAuthHome(client, + peoplesweep.NewCodexCommandStarter(), peoplesweep.NewReleasedCodexIsolationGate(), + filepath.Join(configured.TokensDir(), "people-codex")) + if err != nil { + writeError(w, http.StatusServiceUnavailable, "provider_unavailable", "People inference provider is unavailable") + return + } + credentials := peoplesweep.NewFileCredentialStore(configured.TokensDir()) + var credentialRevision string + if selected.Providers[selected.Provider.Name].Credential == peoplesweep.CredentialStored { + credentialRevision, _, err = credentials.Revision(selected.Provider.Name) + if err != nil { + writeError(w, http.StatusInternalServerError, "credential_store_unavailable", "People provider credential store is unavailable") + return + } + } + resolver := peoplesweep.NewCredentialResolver(credentials, os.LookupEnv) + runner, err := peoplesweep.NewRunner(selected, st, registry, resolver) + if err != nil { + writeError(w, http.StatusServiceUnavailable, "provider_unavailable", "People inference provider is unavailable") + return + } + response, err := runner.Check(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "provider_check_failed", "Synthetic provider check failed") + return + } + if !peoplesweep.DriverVersionMatches(profile.DriverVersion, response.ProviderVersion) { + writeError(w, http.StatusBadGateway, "provider_check_failed", "Synthetic provider check returned the wrong driver") + return + } + if s.operationGate != nil { + done, ok := beginGateWorkBounded(r.Context(), s.operationGate, "people provider check") + if !ok { + writeOperationGateBusy(w, r, s.operationGate) + return + } + defer done() + } + // The policy or key may have changed while the provider was responding. + if _, _, _, _, ok := s.peopleInferenceProfileForRequest(w, r, ifMatch); !ok { + return + } + if credentialRevision != "" { + current, _, err := credentials.Revision(selected.Provider.Name) + if err != nil { + writeError(w, http.StatusInternalServerError, "credential_store_unavailable", "People provider credential store is unavailable") + return + } + if current != credentialRevision { + writeError(w, http.StatusConflict, "credential_conflict", "People provider credential changed during the check; retry") + return + } + } + if _, err := st.EnsurePersonInferenceProfile(r.Context(), profile); err != nil { + writeError(w, http.StatusInternalServerError, "provider_check_store_failed", "Could not record provider check") + return + } + if err := st.RecordPersonInferenceCheck(r.Context(), store.PersonInferenceCheck{ + ProfileFingerprint: profile.Fingerprint, CheckedAt: time.Now().UTC(), + DriverVersion: profile.DriverVersion, OutputMode: profile.OutputMode, + ProviderRequestID: response.ProviderRequestID, ModelVersion: response.ModelVersion, + }); err != nil { + writeError(w, http.StatusInternalServerError, "provider_check_store_failed", "Could not record provider check") + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, PeopleInferenceCheckResponse{ + OK: true, Fingerprint: profile.Fingerprint, Model: profile.Model, Usage: response.Usage, + }) +} + +func (s *Server) handleConsentPeopleInferenceProvider(w http.ResponseWriter, r *http.Request) { + ifMatch, ok := requiredSingleIfMatch(w, r) + if !ok { + return + } + snapshot, configured, _, profile, ok := s.peopleInferenceProfileForRequest(w, r, ifMatch) + if !ok { + return + } + var request PeopleInferenceConsentRequest + if !decodeStrictSettingsJSON(w, r, &request) { + return + } + if !request.Confirmed || request.Fingerprint != profile.Fingerprint { + writeError(w, http.StatusConflict, "consent_disclosure_changed", "Review the exact provider disclosure before consenting") + return + } + st, ok := s.store.(peopleInferenceConsentStore) + if !ok { + writeError(w, http.StatusServiceUnavailable, "people_inference_unavailable", "People inference store is unavailable") + return + } + checked, err := st.HasSuccessfulPersonInferenceCheck(r.Context(), profile.Fingerprint) + if err != nil { + writeError(w, http.StatusInternalServerError, "provider_check_store_failed", "Could not read provider check") + return + } + if !checked { + writeError(w, http.StatusConflict, "check_required", "Run an exact synthetic check before consenting") + return + } + if _, err := st.EnsurePersonInferenceProfile(r.Context(), profile); err != nil { + writeError(w, http.StatusInternalServerError, "consent_store_failed", "Could not record provider consent") + return + } + if _, _, err := st.GrantPersonInferenceConsent(r.Context(), profile.Fingerprint, "web"); err != nil { + writeError(w, http.StatusInternalServerError, "consent_store_failed", "Could not record provider consent") + return + } + response, err := s.buildPeopleInferenceSettingsResponse(r.Context(), configured) + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read people provider status") + return + } + w.Header().Set(etagHeaderName, snapshot.ETag) + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, response) +} + +func (s *Server) handleRevokePeopleInferenceProvider(w http.ResponseWriter, r *http.Request) { + ifMatch, ok := requiredSingleIfMatch(w, r) + if !ok { + return + } + snapshot, configured, _, profile, ok := s.peopleInferenceProfileForRequest(w, r, ifMatch) + if !ok { + return + } + st, ok := s.store.(personenrollment.RevocationStore) + if !ok { + writeError(w, http.StatusServiceUnavailable, "people_inference_unavailable", "People inference store is unavailable") + return + } + if _, err := st.RevokePersonInferenceConsent(r.Context(), profile.Fingerprint, "web"); err != nil { + writeError(w, http.StatusInternalServerError, "consent_revoke_failed", "Could not revoke provider consent") + return + } + if s.cfg.People.Sweep.Enabled && s.cfg.People.Sweep.Provider.Name == r.PathValue("name") { + running, err := s.cfg.People.Sweep.Profile() + if err != nil { + writeError(w, http.StatusInternalServerError, "running_provider_invalid", "Running people provider policy is invalid") + return + } + if running.Fingerprint != profile.Fingerprint { + if _, err := st.RevokePersonInferenceConsent(r.Context(), running.Fingerprint, "web"); err != nil { + writeError(w, http.StatusInternalServerError, "consent_revoke_failed", "Could not revoke running provider consent") + return + } + } + } + response, err := s.buildPeopleInferenceSettingsResponse(r.Context(), configured) + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read people provider status") + return + } + w.Header().Set(etagHeaderName, snapshot.ETag) + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, response) +} + +func (s *Server) handleDisablePeopleInference(w http.ResponseWriter, r *http.Request) { + ifMatch, ok := requiredSingleIfMatch(w, r) + if !ok { + return + } + st, ok := s.store.(personenrollment.RevocationStore) + if !ok { + writeError(w, http.StatusServiceUnavailable, "people_inference_unavailable", "People inference store is unavailable") + return + } + runningFingerprint := "" + if s.cfg.People.Sweep.Enabled { + running, err := s.cfg.People.Sweep.Profile() + if err != nil { + writeError(w, http.StatusInternalServerError, "running_provider_invalid", "Running people provider policy is invalid") + return + } + runningFingerprint = running.Fingerprint + } + service := personenrollment.NewService(s.cfg.ConfigFilePath(), st) + if _, err := service.Disable(r.Context(), ifMatch, runningFingerprint, "web"); err != nil { + s.writeSettingsConfigError(w, err) + return + } + s.settingsPendingRestart.Store(true) + snapshot, configured, err := s.readPersistedSettings() + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read settings") + return + } + response, err := s.buildPeopleInferenceSettingsResponse(r.Context(), configured) + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read people provider status") + return + } + w.Header().Set(etagHeaderName, snapshot.ETag) + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, response) +} + +func (s *Server) handleRemovePeopleInferenceProvider(w http.ResponseWriter, r *http.Request) { + ifMatch, ok := requiredSingleIfMatch(w, r) + if !ok { + return + } + st, ok := s.store.(personenrollment.RevocationStore) + if !ok { + writeError(w, http.StatusServiceUnavailable, "people_inference_unavailable", "People inference store is unavailable") + return + } + name := r.PathValue("name") + if err := peoplesweep.ValidateProviderProfileName(name); err != nil { + writeError(w, http.StatusBadRequest, "invalid_provider_name", "People inference provider name is invalid") + return + } + service := personenrollment.NewService(s.cfg.ConfigFilePath(), st) + credentials := peoplesweep.NewFileCredentialStore(s.cfg.TokensDir()) + if _, err := service.RemoveProfile(r.Context(), ifMatch, name, "web", credentials); err != nil { + switch { + case errors.Is(err, config.ErrConfigConflict): + writeError(w, http.StatusPreconditionFailed, "settings_conflict", "The config file changed; reload settings and retry") + case errors.Is(err, personenrollment.ErrProfileMissing): + writeError(w, http.StatusNotFound, "provider_not_found", "People inference provider was not found") + case errors.Is(err, personenrollment.ErrProfileActive), errors.Is(err, personenrollment.ErrOnlyProfile): + writeError(w, http.StatusConflict, "provider_in_use", err.Error()) + default: + s.writeSettingsConfigError(w, err) + } + return + } + s.settingsPendingRestart.Store(true) + snapshot, configured, err := s.readPersistedSettings() + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read settings") + return + } + response, err := s.buildPeopleInferenceSettingsResponse(r.Context(), configured) + if err != nil { + writeError(w, http.StatusInternalServerError, "settings_read_failed", "Could not read people provider status") + return + } + w.Header().Set(etagHeaderName, snapshot.ETag) + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, response) +} + +func (s *Server) buildPeopleInferenceSettingsResponse(ctx context.Context, configured *config.Config) (PeopleInferenceSettingsResponse, error) { + response := peopleInferenceSettingsResponse(configured.People.Sweep, s.cfg.People.Sweep) + response.StoredCredentialsSupported = peoplesweep.StoredCredentialsSupported() + credentials := peoplesweep.NewFileCredentialStore(configured.TokensDir()) + for index := range response.Profiles { + profile := &response.Profiles[index] + if profile.Protocol == string(peoplesweep.ProtocolCodexAppServer) { + profile.CredentialConfigured = codexAuthFileConfigured(configured.TokensDir()) + continue + } + if profile.CredentialSource == string(peoplesweep.CredentialEnv) { + value, exists := os.LookupEnv(profile.CredentialEnv) + profile.CredentialConfigured = exists && value != "" + continue + } + if profile.CredentialSource != string(peoplesweep.CredentialStored) { + continue + } + if !response.StoredCredentialsSupported { + continue // The platform cannot hold profile secrets, so nothing is stored. + } + revision, exists, err := credentials.Revision(profile.Name) + if err != nil { + return PeopleInferenceSettingsResponse{}, err + } + profile.CredentialRevision = revision + profile.CredentialConfigured = exists + } + if authority, ok := s.store.(peoplesweep.ProviderAuthority); ok { + for index := range response.Profiles { + profile := &response.Profiles[index] + if profile.Fingerprint == "" { + continue + } + checked, err := authority.HasSuccessfulPersonInferenceCheck(ctx, profile.Fingerprint) + if err != nil { + return PeopleInferenceSettingsResponse{}, err + } + consented, err := authority.HasActivePersonInferenceConsent(ctx, profile.Fingerprint) + if err != nil { + return PeopleInferenceSettingsResponse{}, err + } + profile.Checked = checked + profile.ConsentActive = consented + } + } + response.PendingRestart = response.PendingRestart || s.settingsPendingRestart.Load() + return response, nil +} + +func codexAuthFileConfigured(tokensDir string) bool { + home := filepath.Join(tokensDir, "people-codex") + directory, err := os.Lstat(home) //nolint:gosec // Only checks metadata under the configured daemon tokens directory. + if err != nil || !directory.IsDir() || directory.Mode().Perm()&0o077 != 0 { + return false + } + auth, err := os.Lstat(filepath.Join(home, "auth.json")) //nolint:gosec // Fixed child name; metadata only. + return err == nil && auth.Mode().IsRegular() && auth.Mode().Perm()&0o077 == 0 && + auth.Size() > 0 && auth.Size() <= 1<<20 +} + +func peopleInferenceSettingsResponse(configured, running peoplesweep.Config) PeopleInferenceSettingsResponse { + result := PeopleInferenceSettingsResponse{ + Profiles: make([]PeopleInferenceProfileSetting, 0, len(configured.Providers)), + ConfiguredName: configured.Provider.Name, + ConfiguredEnabled: configured.Enabled, + RunningName: running.Provider.Name, + RunningEnabled: running.Enabled, + } + names := make([]string, 0, len(configured.Providers)) + for name := range configured.Providers { + names = append(names, name) + } + slices.Sort(names) + for _, name := range names { + provider := configured.Providers[name] + entry := PeopleInferenceProfileSetting{ + Name: name, Selected: name == configured.Provider.Name, + PresetID: provider.PresetID, + Protocol: string(provider.Protocol), + Model: provider.Model, CredentialSource: string(provider.Credential), + CredentialEnv: provider.CredentialEnv, + OutputMode: string(provider.OutputMode), RetentionPosture: provider.RetentionPosture, + TrainingPosture: provider.TrainingPosture, SourceSince: provider.SourceSince, + SourceUntil: provider.SourceUntil, AllowSensitive: provider.AllowSensitive, + AllowedSources: make([]string, 0, len(provider.AllowedSources)), + } + for _, source := range provider.AllowedSources { + entry.AllowedSources = append(entry.AllowedSources, string(source)) + } + profileConfig := configured + profileConfig.Enabled = true + profileConfig.Provider = peoplesweep.ProviderSelection{Name: name} + if profile, err := profileConfig.Profile(); err == nil { + entry.Fingerprint = profile.Fingerprint + entry.Endpoint = profile.Endpoint + if entry.Selected { + result.ConfiguredFingerprint = profile.Fingerprint + } + } + result.Profiles = append(result.Profiles, entry) + } + if running.Provider.Name != "" { + runningProfileConfig := running + runningProfileConfig.Enabled = true + if profile, err := runningProfileConfig.Profile(); err == nil { + result.RunningFingerprint = profile.Fingerprint + } + } + result.PendingRestart = !reflect.DeepEqual(configured, running) + return result +} diff --git a/internal/api/settings_people_inference_test.go b/internal/api/settings_people_inference_test.go new file mode 100644 index 000000000..ab2ca2780 --- /dev/null +++ b/internal/api/settings_people_inference_test.go @@ -0,0 +1,597 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/config" + "go.kenn.io/msgvault/internal/peoplesweep" + "go.kenn.io/msgvault/internal/personenrollment" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil" +) + +const peopleInferenceSettingsPath = "/api/v1/settings/people-inference" + +func TestPeopleInferenceSettingsReportsConfiguredAndRunningState(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + t.Setenv("TEST_KEY_ENV_NAME", "synthetic-private-api-key") + initial := peopleInferenceSettingsConfig("gpt-first", true) + srv, path := newSettingsTestServer(t, initial) + require.NoError(os.WriteFile(path, []byte(peopleInferenceSettingsConfig("gpt-second", false)), 0o600)) + + resp := performSettingsRequest(t, srv, http.MethodGet, peopleInferenceSettingsPath, nil, "", "") + require.Equal(http.StatusOK, resp.Code, resp.Body.String()) + assert.Equal("no-store", resp.Header().Get("Cache-Control")) + var body PeopleInferenceSettingsResponse + require.NoError(json.Unmarshal(resp.Body.Bytes(), &body)) + assert.False(body.ConfiguredEnabled) + assert.True(body.RunningEnabled) + assert.True(body.PendingRestart) + assert.Equal("primary", body.ConfiguredName) + assert.Equal("primary", body.RunningName) + require.Len(body.Profiles, 1) + assert.Equal("gpt-second", body.Profiles[0].Model) + assert.Equal("TEST_KEY_ENV_NAME", body.Profiles[0].CredentialEnv) + assert.True(body.Profiles[0].CredentialConfigured) + assert.True(body.Profiles[0].Selected) + assert.NotEmpty(body.ConfiguredFingerprint) + assert.NotEmpty(body.RunningFingerprint) + assert.NotEqual(body.ConfiguredFingerprint, body.RunningFingerprint) + assert.NotContains(resp.Body.String(), "synthetic-private-api-key") +} + +func TestPeopleInferenceSelectionRequiresCheckAndConsent(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + srv, path := newSettingsTestServer(t, "") + st := testutil.NewTestStore(t) + srv.store = st + before, err := config.ReadConfigFile(path) + require.NoError(err) + service := personenrollment.NewService(path, st) + created, err := service.CreateProfile(before.ETag, "remote", completeAPIProvider("EXAMPLE_KEY", "example-model")) + require.NoError(err) + request := []byte(`{"name":"remote"}`) + selectPath := peopleInferenceSettingsPath + "/select" + resp := performSettingsRequest(t, srv, http.MethodPost, selectPath, request, created.ETag, "") + assert.Equal(http.StatusConflict, resp.Code, resp.Body.String()) + + configured, err := config.Load(path, "") + require.NoError(err) + selected := configured.People.Sweep + selected.Enabled = true + selected.Provider.Name = "remote" + profile, err := selected.Profile() + require.NoError(err) + _, err = st.EnsurePersonInferenceProfile(context.Background(), profile) + require.NoError(err) + require.NoError(st.RecordPersonInferenceCheck(context.Background(), store.PersonInferenceCheck{ + ProfileFingerprint: profile.Fingerprint, CheckedAt: time.Now(), + DriverVersion: profile.DriverVersion, OutputMode: profile.OutputMode, + ModelVersion: profile.Model, + })) + resp = performSettingsRequest(t, srv, http.MethodPost, selectPath, request, created.ETag, "") + assert.Equal(http.StatusConflict, resp.Code, resp.Body.String()) + _, _, err = st.GrantPersonInferenceConsent(context.Background(), profile.Fingerprint, "test") + require.NoError(err) + resp = performSettingsRequest(t, srv, http.MethodPost, selectPath, request, created.ETag, "") + require.Equal(http.StatusOK, resp.Code, resp.Body.String()) + var body PeopleInferenceSettingsResponse + require.NoError(json.Unmarshal(resp.Body.Bytes(), &body)) + assert.Equal("remote", body.ConfiguredName) + assert.True(body.ConfiguredEnabled) + assert.Equal(profile.Fingerprint, body.ConfiguredFingerprint) + assert.True(body.PendingRestart) + assert.NotEmpty(resp.Header().Get("ETag")) +} + +func TestPeopleInferencePresetCreationRequiresSupportedStorage(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + srv, _ := newSettingsTestServer(t, "") + read := performSettingsRequest(t, srv, http.MethodGet, peopleInferenceSettingsPath, nil, "", "") + require.Equal(http.StatusOK, read.Code, read.Body.String()) + request := []byte(`{"preset_id":"openrouter","model":"example/model","retention_posture":"operator-confirmed","training_posture":"operator-confirmed","allowed_sources":["conversation_text"],"source_since":"2025-01-01","allow_sensitive":false}`) + created := performSettingsRequest(t, srv, http.MethodPut, + peopleInferenceSettingsPath+"/providers/remote", request, read.Header().Get("ETag"), "") + if !peoplesweep.StoredCredentialsSupported() { + require.Equal(http.StatusServiceUnavailable, created.Code, created.Body.String()) + assert.Contains(created.Body.String(), "--credential-env on the daemon host") + after := performSettingsRequest(t, srv, http.MethodGet, peopleInferenceSettingsPath, nil, "", "") + require.Equal(http.StatusOK, after.Code, after.Body.String()) + assert.Equal(read.Header().Get("ETag"), after.Header().Get("ETag")) + var body PeopleInferenceSettingsResponse + require.NoError(json.Unmarshal(after.Body.Bytes(), &body)) + assert.False(body.StoredCredentialsSupported) + assert.JSONEq(read.Body.String(), after.Body.String()) + return + } + require.Equal(http.StatusOK, created.Code, created.Body.String()) + var body PeopleInferenceSettingsResponse + require.NoError(json.Unmarshal(created.Body.Bytes(), &body)) + assert.True(body.StoredCredentialsSupported) + assert.False(body.ConfiguredEnabled) + assert.True(body.PendingRestart) + require.Len(body.Profiles, 1) + var found *PeopleInferenceProfileSetting + for index := range body.Profiles { + if body.Profiles[index].Name == "remote" { + found = &body.Profiles[index] + } + } + require.NotNil(found) + assert.Equal("https://openrouter.ai/api/v1", found.Endpoint) + assert.Equal("openrouter", found.PresetID) + assert.Equal("stored", found.CredentialSource) + assert.NotEmpty(found.Fingerprint) + assert.NotContains(created.Body.String(), "api_key") + stale := performSettingsRequest(t, srv, http.MethodPut, + peopleInferenceSettingsPath+"/providers/other", request, read.Header().Get("ETag"), "") + assert.Equal(http.StatusPreconditionFailed, stale.Code) +} + +func TestPeopleInferencePresetRejectsDaemonEnvironmentReference(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + t.Setenv("TEST_PEOPLE_PROVIDER_KEY", "synthetic-key") + srv, _ := newSettingsTestServer(t, "") + read := performSettingsRequest(t, srv, http.MethodGet, peopleInferenceSettingsPath, nil, "", "") + require.Equal(http.StatusOK, read.Code) + request := []byte(`{"preset_id":"venice","model":"example/model","credential_env":"TEST_PEOPLE_PROVIDER_KEY","retention_posture":"operator-confirmed","training_posture":"operator-confirmed","allowed_sources":["conversation_text"],"source_since":"2025-01-01","allow_sensitive":false}`) + created := performSettingsRequest(t, srv, http.MethodPut, + peopleInferenceSettingsPath+"/providers/from-env", request, read.Header().Get("ETag"), "") + require.Equal(http.StatusBadRequest, created.Code, created.Body.String()) + assert.NotContains(created.Body.String(), "synthetic-key") + after := performSettingsRequest(t, srv, http.MethodGet, peopleInferenceSettingsPath, nil, "", "") + assert.Equal(read.Header().Get("ETag"), after.Header().Get("ETag")) +} + +type failingPeopleInferenceStore struct{ *store.Store } + +func (f failingPeopleInferenceStore) HasSuccessfulPersonInferenceCheck(context.Context, string) (bool, error) { + return false, errors.New("database unavailable") +} + +func TestPeopleInferenceSelectionReportsStoreFailureAsServerError(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + srv, path := newSettingsTestServer(t, "") + srv.store = failingPeopleInferenceStore{Store: testutil.NewTestStore(t)} + before, err := config.ReadConfigFile(path) + require.NoError(err) + service := personenrollment.NewService(path, nil) + created, err := service.CreateProfile(before.ETag, "remote", completeAPIProvider("EXAMPLE_KEY", "example-model")) + require.NoError(err) + resp := performSettingsRequest(t, srv, http.MethodPost, + peopleInferenceSettingsPath+"/select", []byte(`{"name":"remote"}`), created.ETag, "") + assert.Equal(http.StatusInternalServerError, resp.Code) + assert.NotContains(resp.Body.String(), "database unavailable") +} + +func TestPeopleInferenceKeyWriteUsesSeparateRevisionAndInvalidatesAuthority(t *testing.T) { + if !peoplesweep.StoredCredentialsSupported() { + t.Skip("stored people provider credentials are unsupported on this platform") + } + assert := assert.New(t) + require := require.New(t) + srv, path := newSettingsTestServer(t, "") + st := testutil.NewTestStore(t) + srv.store = st + before, err := config.ReadConfigFile(path) + require.NoError(err) + service := personenrollment.NewService(path, st) + provider, err := peoplesweep.PresetProviderConfig("openrouter", "example-model") + require.NoError(err) + provider.RetentionPosture = "operator-confirmed" + provider.TrainingPosture = "operator-confirmed" + provider.AllowedSources = []peoplesweep.SourceClass{peoplesweep.SourceConversationText} + provider.SourceSince = "2025-01-01" + created, err := service.CreateProfile(before.ETag, "remote", provider) + require.NoError(err) + get := performSettingsRequest(t, srv, http.MethodGet, peopleInferenceSettingsPath, nil, "", "") + require.Equal(http.StatusOK, get.Code, get.Body.String()) + var status PeopleInferenceSettingsResponse + require.NoError(json.Unmarshal(get.Body.Bytes(), &status)) + require.Len(status.Profiles, 1) + initialRevision := status.Profiles[0].CredentialRevision + assert.NotEmpty(initialRevision) + assert.False(status.Profiles[0].CredentialConfigured) + assert.Equal(created.ETag, get.Header().Get("ETag")) + + pathKey := peopleInferenceSettingsPath + "/providers/remote/key" + write := func(revision, secret string) *httptest.ResponseRecorder { + request := httptest.NewRequest(http.MethodPut, pathKey, strings.NewReader(`{"value":"`+secret+`"}`)) + request.RemoteAddr = "127.0.0.1:12345" + request.Header.Set("Content-Type", "application/json") + request.Header.Set("If-Match", revision) + response := httptest.NewRecorder() + srv.Router().ServeHTTP(response, request) + return response + } + first := write(initialRevision, "first-secret") + require.Equal(http.StatusOK, first.Code, first.Body.String()) + assert.NotContains(first.Body.String(), "first-secret") + var firstStatus PeopleInferenceSettingsResponse + require.NoError(json.Unmarshal(first.Body.Bytes(), &firstStatus)) + firstRevision := firstStatus.Profiles[0].CredentialRevision + assert.NotEqual(initialRevision, firstRevision) + assert.True(firstStatus.Profiles[0].CredentialConfigured) + stale := write(initialRevision, "stale-secret") + assert.Equal(http.StatusPreconditionFailed, stale.Code) + credential, err := peoplesweep.NewFileCredentialStore(srv.cfg.TokensDir()).Load("remote") + require.NoError(err) + assert.Equal("first-secret", credential.Value()) + + configured, err := config.Load(path, "") + require.NoError(err) + selected := configured.People.Sweep + selected.Enabled = true + selected.Provider.Name = "remote" + profile, err := selected.Profile() + require.NoError(err) + _, err = st.EnsurePersonInferenceProfile(t.Context(), profile) + require.NoError(err) + require.NoError(st.RecordPersonInferenceCheck(t.Context(), store.PersonInferenceCheck{ + ProfileFingerprint: profile.Fingerprint, CheckedAt: time.Now(), DriverVersion: profile.DriverVersion, + OutputMode: profile.OutputMode, ModelVersion: profile.Model, + })) + _, _, err = st.GrantPersonInferenceConsent(t.Context(), profile.Fingerprint, "test") + require.NoError(err) + // The daemon still runs the old model while the saved policy awaits restart. + srv.cfg.People.Sweep = selected + runningProvider := srv.cfg.People.Sweep.Providers["remote"] + runningProvider.Model = "previous-model" + srv.cfg.People.Sweep.Providers["remote"] = runningProvider + runningProfile, err := srv.cfg.People.Sweep.Profile() + require.NoError(err) + _, err = st.EnsurePersonInferenceProfile(t.Context(), runningProfile) + require.NoError(err) + grantRunning := func() { + require.NoError(st.RecordPersonInferenceCheck(t.Context(), store.PersonInferenceCheck{ + ProfileFingerprint: runningProfile.Fingerprint, CheckedAt: time.Now(), + DriverVersion: runningProfile.DriverVersion, OutputMode: runningProfile.OutputMode, + ModelVersion: runningProfile.Model, + })) + _, _, err := st.GrantPersonInferenceConsent(t.Context(), runningProfile.Fingerprint, "test") + require.NoError(err) + } + assertRunningRevoked := func() { + checked, err := st.HasSuccessfulPersonInferenceCheck(t.Context(), runningProfile.Fingerprint) + require.NoError(err) + assert.False(checked) + consented, err := st.HasActivePersonInferenceConsent(t.Context(), runningProfile.Fingerprint) + require.NoError(err) + assert.False(consented) + } + grantRunning() + second := write(firstRevision, "second-secret") + require.Equal(http.StatusOK, second.Code, second.Body.String()) + assertRunningRevoked() + checked, err := st.HasSuccessfulPersonInferenceCheck(t.Context(), profile.Fingerprint) + require.NoError(err) + assert.False(checked) + consented, err := st.HasActivePersonInferenceConsent(t.Context(), profile.Fingerprint) + require.NoError(err) + assert.False(consented) + var secondStatus PeopleInferenceSettingsResponse + require.NoError(json.Unmarshal(second.Body.Bytes(), &secondStatus)) + secondRevision := secondStatus.Profiles[0].CredentialRevision + require.NoError(st.RecordPersonInferenceCheck(t.Context(), store.PersonInferenceCheck{ + ProfileFingerprint: profile.Fingerprint, CheckedAt: time.Now(), DriverVersion: profile.DriverVersion, + OutputMode: profile.OutputMode, ModelVersion: profile.Model, + })) + _, _, err = st.GrantPersonInferenceConsent(t.Context(), profile.Fingerprint, "test") + require.NoError(err) + grantRunning() + removed := performSettingsRequest(t, srv, http.MethodDelete, pathKey, nil, secondRevision, "") + require.Equal(http.StatusOK, removed.Code, removed.Body.String()) + assertRunningRevoked() + var removedStatus PeopleInferenceSettingsResponse + require.NoError(json.Unmarshal(removed.Body.Bytes(), &removedStatus)) + assert.False(removedStatus.Profiles[0].CredentialConfigured) + assert.NotEqual(secondRevision, removedStatus.Profiles[0].CredentialRevision) + _, err = peoplesweep.NewFileCredentialStore(srv.cfg.TokensDir()).Load("remote") + require.ErrorIs(err, peoplesweep.ErrCredentialNotFound) + checked, err = st.HasSuccessfulPersonInferenceCheck(t.Context(), profile.Fingerprint) + require.NoError(err) + assert.False(checked) + consented, err = st.HasActivePersonInferenceConsent(t.Context(), profile.Fingerprint) + require.NoError(err) + assert.False(consented) +} + +type peopleInferenceRewriteTransport struct{ target *url.URL } + +func (t peopleInferenceRewriteTransport) RoundTrip(request *http.Request) (*http.Response, error) { + cloned := request.Clone(request.Context()) + copyURL := *request.URL + copyURL.Scheme = t.target.Scheme + copyURL.Host = t.target.Host + cloned.URL = ©URL + return http.DefaultTransport.RoundTrip(cloned) +} + +func TestPeopleInferenceCheckRejectsKeyChangedDuringRequest(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + if !peoplesweep.StoredCredentialsSupported() { + t.Skip("stored credentials require Unix permissions") + } + arrived, respond := make(chan struct{}), make(chan struct{}) + providerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(arrived) + select { + case <-respond: + case <-r.Context().Done(): + return + } + _, err := io.WriteString(w, `{"model":"example-model","choices":[{"message":{"content":"{\"ok\":true}"},"finish_reason":"stop"}]}`) + assert.NoError(err) + })) + defer providerServer.Close() + defer close(respond) + srv, path := newSettingsTestServer(t, "") + st := testutil.NewTestStore(t) + srv = NewServerWithOptions(ServerOptions{Config: srv.cfg, Store: st, Logger: srv.logger, OperationGate: NewSerialOperationGate()}) + target, err := url.Parse(providerServer.URL) + require.NoError(err) + srv.peopleInferenceHTTPClient = &http.Client{Transport: peopleInferenceRewriteTransport{target: target}} + before, err := config.ReadConfigFile(path) + require.NoError(err) + provider, err := peoplesweep.PresetProviderConfig("openai", "example-model") + require.NoError(err) + provider.RetentionPosture, provider.TrainingPosture = "operator-confirmed", "operator-confirmed" + provider.AllowedSources = []peoplesweep.SourceClass{peoplesweep.SourceConversationText} + provider.SourceSince = "2025-01-01" + created, err := personenrollment.NewService(path, st).CreateProfile(before.ETag, "remote", provider) + require.NoError(err) + credentials := peoplesweep.NewFileCredentialStore(srv.cfg.TokensDir()) + revision, _, err := credentials.Revision("remote") + require.NoError(err) + keyPath := peopleInferenceSettingsPath + "/providers/remote/key" + saved := performSettingsRequest(t, srv, http.MethodPut, keyPath, []byte(`{"value":"first-key"}`), revision, "") + require.Equal(http.StatusOK, saved.Code, saved.Body.String()) + finished := make(chan *httptest.ResponseRecorder, 1) + go func() { + finished <- performSettingsRequest(t, srv, http.MethodPost, + peopleInferenceSettingsPath+"/providers/remote/check", nil, created.ETag, "") + }() + select { + case <-arrived: + case <-time.After(5 * time.Second): + require.FailNow("provider did not receive the check") + } + revision, _, err = credentials.Revision("remote") + require.NoError(err) + changed := performSettingsRequest(t, srv, http.MethodPut, keyPath, []byte(`{"value":"second-key"}`), revision, "") + require.Equal(http.StatusOK, changed.Code, changed.Body.String()) + respond <- struct{}{} + check := <-finished + assert.Equal(http.StatusConflict, check.Code, check.Body.String()) + assert.Contains(check.Body.String(), "credential_conflict") + checked, err := st.HasSuccessfulPersonInferenceCheck(t.Context(), created.Fingerprint) + require.NoError(err) + assert.False(checked) +} + +func TestPeopleInferenceAPICheckConsentAndSelectUsesSyntheticProviderPath(t *testing.T) { + if !peoplesweep.StoredCredentialsSupported() { + t.Skip("stored people provider credentials are unsupported on this platform") + } + assert := assert.New(t) + require := require.New(t) + var seen atomic.Bool + gate := NewSerialOperationGate() + providerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen.Store(true) + _, _, held := gate.Holder() + assert.False(held, "provider network calls must not hold the archive operation gate") + assert.Equal("/api/v1/chat/completions", r.URL.Path) + assert.Equal("Bearer synthetic-key", r.Header.Get("Authorization")) + body, err := io.ReadAll(r.Body) + if !assert.NoError(err) { + return + } + assert.Contains(string(body), "Return an object with ok set to true") + assert.NotContains(string(body), "archive-private-canary") + _, err = io.WriteString(w, `{"model":"example-model","choices":[{"message":{"content":"{\"ok\":true}"},"finish_reason":"stop"}],"usage":{"prompt_tokens":12,"completion_tokens":3}}`) + assert.NoError(err) + })) + defer providerServer.Close() + target, err := url.Parse(providerServer.URL) + require.NoError(err) + + srv, path := newSettingsTestServer(t, "") + st := testutil.NewTestStore(t) + srv = NewServerWithOptions(ServerOptions{Config: srv.cfg, Store: st, Logger: srv.logger, OperationGate: gate}) + srv.peopleInferenceHTTPClient = &http.Client{Transport: peopleInferenceRewriteTransport{target: target}} + before, err := config.ReadConfigFile(path) + require.NoError(err) + provider, err := peoplesweep.PresetProviderConfig("openrouter", "example-model") + require.NoError(err) + provider.RetentionPosture = "operator-confirmed" + provider.TrainingPosture = "operator-confirmed" + provider.AllowedSources = []peoplesweep.SourceClass{peoplesweep.SourceConversationText} + provider.SourceSince = "2025-01-01" + created, err := personenrollment.NewService(path, st).CreateProfile(before.ETag, "remote", provider) + require.NoError(err) + get := performSettingsRequest(t, srv, http.MethodGet, peopleInferenceSettingsPath, nil, "", "") + require.Equal(http.StatusOK, get.Code) + var status PeopleInferenceSettingsResponse + require.NoError(json.Unmarshal(get.Body.Bytes(), &status)) + require.Len(status.Profiles, 1) + revision := status.Profiles[0].CredentialRevision + keyRequest := httptest.NewRequest(http.MethodPut, + peopleInferenceSettingsPath+"/providers/remote/key", strings.NewReader(`{"value":"synthetic-key"}`)) + keyRequest.RemoteAddr = "127.0.0.1:12345" + keyRequest.Header.Set("Content-Type", "application/json") + keyRequest.Header.Set("If-Match", revision) + keyResponse := httptest.NewRecorder() + srv.Router().ServeHTTP(keyResponse, keyRequest) + require.Equal(http.StatusOK, keyResponse.Code, keyResponse.Body.String()) + + check := performSettingsRequest(t, srv, http.MethodPost, + peopleInferenceSettingsPath+"/providers/remote/check", nil, created.ETag, "") + require.Equal(http.StatusOK, check.Code, check.Body.String()) + assert.True(seen.Load()) + var checked PeopleInferenceCheckResponse + require.NoError(json.Unmarshal(check.Body.Bytes(), &checked)) + assert.True(checked.OK) + assert.NotEmpty(checked.Fingerprint) + assert.Equal("example-model", checked.Model) + consent := performSettingsRequest(t, srv, http.MethodPost, + peopleInferenceSettingsPath+"/providers/remote/consent", + []byte(`{"fingerprint":"`+checked.Fingerprint+`","confirmed":true}`), created.ETag, "") + require.Equal(http.StatusOK, consent.Code, consent.Body.String()) + var consentStatus PeopleInferenceSettingsResponse + require.NoError(json.Unmarshal(consent.Body.Bytes(), &consentStatus)) + require.Len(consentStatus.Profiles, 1) + assert.True(consentStatus.Profiles[0].Checked) + assert.True(consentStatus.Profiles[0].ConsentActive) + selected := performSettingsRequest(t, srv, http.MethodPost, + peopleInferenceSettingsPath+"/select", []byte(`{"name":"remote"}`), created.ETag, "") + require.Equal(http.StatusOK, selected.Code, selected.Body.String()) + selectedETag := selected.Header().Get("ETag") + runningConfig, err := config.Load(path, "") + require.NoError(err) + srv.cfg.People.Sweep = runningConfig.People.Sweep + runningProvider := srv.cfg.People.Sweep.Providers["remote"] + runningProvider.Model = "older-model" + srv.cfg.People.Sweep.Providers["remote"] = runningProvider + runningProfile, err := srv.cfg.People.Sweep.Profile() + require.NoError(err) + _, err = st.EnsurePersonInferenceProfile(t.Context(), runningProfile) + require.NoError(err) + _, _, err = st.GrantPersonInferenceConsent(t.Context(), runningProfile.Fingerprint, "test") + require.NoError(err) + revoked := performSettingsRequest(t, srv, http.MethodPost, + peopleInferenceSettingsPath+"/providers/remote/revoke", nil, selectedETag, "") + require.Equal(http.StatusOK, revoked.Code, revoked.Body.String()) + var revokedStatus PeopleInferenceSettingsResponse + require.NoError(json.Unmarshal(revoked.Body.Bytes(), &revokedStatus)) + assert.False(revokedStatus.Profiles[0].ConsentActive) + runningConsent, err := st.HasActivePersonInferenceConsent(t.Context(), runningProfile.Fingerprint) + require.NoError(err) + assert.False(runningConsent) + _, _, err = st.GrantPersonInferenceConsent(t.Context(), checked.Fingerprint, "test") + require.NoError(err) + disabled := performSettingsRequest(t, srv, http.MethodPost, + peopleInferenceSettingsPath+"/disable", nil, selectedETag, "") + require.Equal(http.StatusOK, disabled.Code, disabled.Body.String()) + var disabledStatus PeopleInferenceSettingsResponse + require.NoError(json.Unmarshal(disabled.Body.Bytes(), &disabledStatus)) + assert.False(disabledStatus.ConfiguredEnabled) + consented, err := st.HasActivePersonInferenceConsent(t.Context(), checked.Fingerprint) + require.NoError(err) + assert.False(consented) + blockedRemove := performSettingsRequest(t, srv, http.MethodDelete, + peopleInferenceSettingsPath+"/providers/remote", nil, disabled.Header().Get("ETag"), "") + assert.Equal(http.StatusConflict, blockedRemove.Code) + invalidRemove := performSettingsRequest(t, srv, http.MethodDelete, + peopleInferenceSettingsPath+"/providers/bad%20name", nil, disabled.Header().Get("ETag"), "") + assert.Equal(http.StatusBadRequest, invalidRemove.Code) + backup, err := personenrollment.NewService(path, st).CreateProfile(disabled.Header().Get("ETag"), "backup", provider) + require.NoError(err) + removed := performSettingsRequest(t, srv, http.MethodDelete, + peopleInferenceSettingsPath+"/providers/remote", nil, backup.ETag, "") + require.Equal(http.StatusOK, removed.Code, removed.Body.String()) + var removedStatus PeopleInferenceSettingsResponse + require.NoError(json.Unmarshal(removed.Body.Bytes(), &removedStatus)) + require.Len(removedStatus.Profiles, 1) + assert.Equal("backup", removedStatus.Profiles[0].Name) + credentials := peoplesweep.NewFileCredentialStore(srv.cfg.TokensDir()) + _, err = credentials.Load("remote") + assert.ErrorIs(err, peoplesweep.ErrCredentialNotFound) +} + +func TestPeopleInferenceKeyMutationDoesNotRequireRestart(t *testing.T) { + if !peoplesweep.StoredCredentialsSupported() { + t.Skip("stored people provider credentials are unsupported on this platform") + } + assert := assert.New(t) + require := require.New(t) + srv, _ := newSettingsTestServer(t, `[people.sweep] +enabled = false +provider = "remote" + +[people.sweep.providers.remote] +preset_id = "openrouter" +protocol = "openai_chat" +endpoint = "https://openrouter.ai/api/v1" +model = "example-model" +auth = "bearer" +credential = "stored" +output_mode = "native_json_schema" +token_limit_parameter = "max_completion_tokens" +retention_posture = "operator-confirmed" +training_posture = "operator-confirmed" +allowed_sources = ["conversation_text"] +source_since = "2025-01-01" +request_timeout = "1m" +`) + srv.store = testutil.NewTestStore(t) + get := performSettingsRequest(t, srv, http.MethodGet, peopleInferenceSettingsPath, nil, "", "") + require.Equal(http.StatusOK, get.Code, get.Body.String()) + var initial PeopleInferenceSettingsResponse + require.NoError(json.Unmarshal(get.Body.Bytes(), &initial)) + require.Len(initial.Profiles, 1) + assert.False(initial.PendingRestart) + pathKey := peopleInferenceSettingsPath + "/providers/remote/key" + request := httptest.NewRequest(http.MethodPut, pathKey, strings.NewReader(`{"value":"synthetic-key"}`)) + request.RemoteAddr = "127.0.0.1:12345" + request.Header.Set("Content-Type", "application/json") + request.Header.Set("If-Match", initial.Profiles[0].CredentialRevision) + written := httptest.NewRecorder() + srv.Router().ServeHTTP(written, request) + require.Equal(http.StatusOK, written.Code, written.Body.String()) + var afterWrite PeopleInferenceSettingsResponse + require.NoError(json.Unmarshal(written.Body.Bytes(), &afterWrite)) + assert.False(afterWrite.PendingRestart) + removed := performSettingsRequest(t, srv, http.MethodDelete, pathKey, nil, + afterWrite.Profiles[0].CredentialRevision, "") + require.Equal(http.StatusOK, removed.Code, removed.Body.String()) + var afterDelete PeopleInferenceSettingsResponse + require.NoError(json.Unmarshal(removed.Body.Bytes(), &afterDelete)) + assert.False(afterDelete.PendingRestart) +} + +func peopleInferenceSettingsConfig(model string, enabled bool) string { + status := "false" + if enabled { + status = "true" + } + content := strings.ReplaceAll(`[people.sweep] +enabled = ENABLED +provider = "primary" + +[people.sweep.providers.primary] +protocol = "openai_chat" +endpoint = "https://api.example.test/v1" +model = "MODEL" +auth = "bearer" +credential = "env" +credential_env = "TEST_KEY_ENV_NAME" +output_mode = "native_json_schema" +token_limit_parameter = "max_completion_tokens" +retention_posture = "zero_retention" +training_posture = "no_training" +allowed_sources = ["conversation_text"] +source_since = "2025-01-01" +request_timeout = "45s" +`, "ENABLED", status) + return strings.ReplaceAll(content, "MODEL", model) +} diff --git a/internal/peoplesweep/capability_check.go b/internal/peoplesweep/capability_check.go index 10cb0c7b2..f0022dda3 100644 --- a/internal/peoplesweep/capability_check.go +++ b/internal/peoplesweep/capability_check.go @@ -326,6 +326,7 @@ func capabilityProfile( credentialSource = CredentialNone } provider := ProviderConfig{ + PresetID: candidate.PresetID, Protocol: candidate.Protocol, Endpoint: candidate.Endpoint, Model: candidate.Model, Auth: candidate.Auth, Credential: credentialSource, OutputMode: mode, TokenLimitParameter: tokenParameter, diff --git a/internal/peoplesweep/capability_check_test.go b/internal/peoplesweep/capability_check_test.go index 2a95dd5dd..8217f7114 100644 --- a/internal/peoplesweep/capability_check_test.go +++ b/internal/peoplesweep/capability_check_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "net" "net/http" "net/http/httptest" "reflect" @@ -17,6 +18,81 @@ import ( "github.com/stretchr/testify/require" ) +func TestProviderPresetNegotiationRoutesStrictAndFallsBackForVenice(t *testing.T) { + for _, test := range []struct { + name, preset, unsupportedSchemaBody string + }{ + {name: "openrouter", preset: "openrouter"}, + {name: "venice representation code", preset: "venice", unsupportedSchemaBody: `{"error":{"type":"invalid_request_error","code":"unsupported_json_schema"}}`}, + {name: "venice parameter code", preset: "venice", unsupportedSchemaBody: `{"error":{"type":"invalid_request_error","code":"unsupported_parameter","param":"response_format"}}`}, + } { + t.Run(test.name, func(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + var requests []map[string]any + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/chat/completions", r.URL.Path) + assert.Equal(t, "Bearer synthetic-key", r.Header.Get("Authorization")) + var body map[string]any + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + requests = append(requests, body) + format := responseFormatType(t, body) + if test.unsupportedSchemaBody != "" && format == "json_schema" { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(test.unsupportedSchemaBody)) + return + } + _, _ = w.Write([]byte(`{"model":"synthetic-model","choices":[{"message":{"content":"{\"claims\":[]}"}}]}`)) + })) + t.Cleanup(server.Close) + serverTransport, ok := server.Client().Transport.(*http.Transport) + requireChecks.True(ok) + transport := serverTransport.Clone() + transport.TLSClientConfig.ServerName = "127.0.0.1" + transport.DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, server.Listener.Addr().String()) + } + client := &http.Client{Transport: transport} + + candidate, err := PresetProviderConfig(test.preset, "synthetic-model") + requireChecks.NoError(err) + candidate.RequestTimeout = time.Second + registry, err := NewDriverRegistry(client, nil, nil) + requireChecks.NoError(err) + got, err := NewCapabilityChecker(registry).Negotiate(t.Context(), candidate, NewCredential(AuthBearer, "synthetic-key")) + requireChecks.NoError(err) + if test.preset == "venice" { + assertChecks.Equal(OutputModeJSONObject, got.OutputMode) + } else { + assertChecks.Equal(OutputModeNativeJSONSchema, got.OutputMode) + assertChecks.Equal(map[string]any{"require_parameters": true}, requests[0]["provider"]) + } + candidate.OutputMode = got.OutputMode + candidate.TokenLimitParameter = got.TokenLimitParameter + candidate.RetentionPosture = "operator_asserted" + candidate.TrainingPosture = "operator_asserted" + candidate.AllowedSources = []SourceClass{SourceConversationText} + candidate.SourceSince = "2025-01-01" + config := Config{Enabled: true, Provider: ProviderSelection{Name: "provider"}, Providers: map[string]ProviderConfig{"provider": candidate}} + config.ApplyDefaults() + profile, err := config.Profile() + requireChecks.NoError(err) + driver := NewOpenAIChatDriver(client) + prepared, err := driver.Prepare(profile, capabilitySyntheticRequest()) + requireChecks.NoError(err) + _, err = driver.GeneratePrepared(t.Context(), profile, NewCredential(AuthBearer, "synthetic-key"), prepared) + requireChecks.NoError(err) + assertChecks.Equal(requests[len(requests)-2], requests[len(requests)-1], + "negotiation and production must send the same selected representation") + assertChecks.Equal(requests[len(requests)-1], func() map[string]any { + var body map[string]any + require.NoError(t, json.Unmarshal(prepared.WireRequest(), &body)) + return body + }()) + }) + } +} + const ( capabilityArchiveCanary = "archive-message-canary-never-send" capabilityCredentialValue = "credential-canary-never-report" @@ -38,21 +114,21 @@ type capabilityAttempt struct { func TestCapabilityNegotiationUsesFixedOutputAndTokenOrderWithoutArchiveContext(t *testing.T) { newAssert := assert.New - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) var mu sync.Mutex var attempts []capabilityAttempt statuses := []int{http.StatusBadRequest, http.StatusUnprocessableEntity, http.StatusNotFound, http.StatusOK} server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert := newAssert(t) + assertChecks := newAssert(t) var body map[string]any - assert.NoError(json.NewDecoder(r.Body).Decode(&body)) + assertChecks.NoError(json.NewDecoder(r.Body).Decode(&body)) encoded, err := json.Marshal(body) - assert.NoError(err) - assert.NotContains(string(encoded), capabilityArchiveCanary) - assert.NotContains(string(encoded), capabilityCredentialValue) - assert.Equal("Bearer "+capabilityCredentialValue, r.Header.Get("Authorization")) + assertChecks.NoError(err) + assertChecks.NotContains(string(encoded), capabilityArchiveCanary) + assertChecks.NotContains(string(encoded), capabilityCredentialValue) + assertChecks.Equal("Bearer "+capabilityCredentialValue, r.Header.Get("Authorization")) mu.Lock() attempt := len(attempts) @@ -69,7 +145,7 @@ func TestCapabilityNegotiationUsesFixedOutputAndTokenOrderWithoutArchiveContext( t.Cleanup(server.Close) registry, err := NewDriverRegistry(server.Client(), nil, nil) - require.NoError(err) + requireChecks.NoError(err) candidate := capabilityTestCandidate(ProtocolOpenAIChat, server.URL) candidate.RetentionPosture = capabilityArchiveCanary candidate.TrainingPosture = capabilityArchiveCanary @@ -80,28 +156,28 @@ func TestCapabilityNegotiationUsesFixedOutputAndTokenOrderWithoutArchiveContext( got, err := NewCapabilityChecker(registry).Negotiate(t.Context(), candidate, NewCredential(AuthBearer, capabilityCredentialValue)) - require.NoError(err) - assert.Equal(OutputModeJSONObject, got.OutputMode) - assert.Equal("max_tokens", got.TokenLimitParameter) - assert.Equal(defaultDriverVersion(ProtocolOpenAIChat), got.DriverVersion) - assert.JSONEq(`{"claims":[]}`, string(got.Response.Output)) + requireChecks.NoError(err) + assertChecks.Equal(OutputModeJSONObject, got.OutputMode) + assertChecks.Equal("max_tokens", got.TokenLimitParameter) + assertChecks.Equal(defaultDriverVersion(ProtocolOpenAIChat), got.DriverVersion) + assertChecks.JSONEq(`{"claims":[]}`, string(got.Response.Output)) mu.Lock() defer mu.Unlock() - require.Len(attempts, 4) + requireChecks.Len(attempts, 4) for _, attempt := range attempts { - assert.Equal("/chat/completions", attempt.path) - assert.Equal("synthetic-model", attempt.body["model"]) - assert.NotContains(attempt.body, "max_output_tokens") + assertChecks.Equal("/chat/completions", attempt.path) + assertChecks.Equal("synthetic-model", attempt.body["model"]) + assertChecks.NotContains(attempt.body, "max_output_tokens") } - assert.Equal("json_schema", responseFormatType(t, attempts[0].body)) - assert.Contains(attempts[0].body, "max_completion_tokens") - assert.Equal("json_schema", responseFormatType(t, attempts[1].body)) - assert.Contains(attempts[1].body, "max_tokens") - assert.Equal("json_object", responseFormatType(t, attempts[2].body)) - assert.Contains(attempts[2].body, "max_completion_tokens") - assert.Equal("json_object", responseFormatType(t, attempts[3].body)) - assert.Contains(attempts[3].body, "max_tokens") + assertChecks.Equal("json_schema", responseFormatType(t, attempts[0].body)) + assertChecks.Contains(attempts[0].body, "max_completion_tokens") + assertChecks.Equal("json_schema", responseFormatType(t, attempts[1].body)) + assertChecks.Contains(attempts[1].body, "max_tokens") + assertChecks.Equal("json_object", responseFormatType(t, attempts[2].body)) + assertChecks.Contains(attempts[2].body, "max_completion_tokens") + assertChecks.Equal("json_object", responseFormatType(t, attempts[3].body)) + assertChecks.Contains(attempts[3].body, "max_tokens") } func TestCapabilityNegotiationChecksRequestedReasoningSeparately(t *testing.T) { @@ -115,22 +191,22 @@ func TestCapabilityNegotiationChecksRequestedReasoningSeparately(t *testing.T) { } { t.Run(test.name, func(t *testing.T) { newAssert := assert.New - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) var calls atomic.Int32 server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert := newAssert(t) + assertChecks := newAssert(t) call := calls.Add(1) var body map[string]any - assert.NoError(json.NewDecoder(r.Body).Decode(&body)) + assertChecks.NoError(json.NewDecoder(r.Body).Decode(&body)) if call == 1 { - assert.NotContains(body, "reasoning_effort") - assert.NotContains(body, "reasoning") + assertChecks.NotContains(body, "reasoning_effort") + assertChecks.NotContains(body, "reasoning") } else { - assert.Equal("high", body["reasoning_effort"]) + assertChecks.Equal("high", body["reasoning_effort"]) reasoning, ok := body["reasoning"].(map[string]any) - if assert.True(ok) { - assert.Equal(true, reasoning["enabled"]) + if assertChecks.True(ok) { + assertChecks.Equal(true, reasoning["enabled"]) } } if call == 2 && test.reasonCode != http.StatusOK { @@ -142,24 +218,24 @@ func TestCapabilityNegotiationChecksRequestedReasoningSeparately(t *testing.T) { })) t.Cleanup(server.Close) registry, err := NewDriverRegistry(server.Client(), nil, nil) - require.NoError(err) + requireChecks.NoError(err) candidate := capabilityTestCandidate(ProtocolOpenAIChat, server.URL) candidate.ReasoningEffort = "high" candidate.ReasoningMode = "enabled" got, negotiationErr := NewCapabilityChecker(registry).Negotiate(t.Context(), candidate, NewCredential(AuthBearer, capabilityCredentialValue)) - assert.Equal(int32(2), calls.Load()) + assertChecks.Equal(int32(2), calls.Load()) if test.wantErr { - require.Error(negotiationErr) - assert.Empty(got) - assert.NotContains(negotiationErr.Error(), capabilityResponseCanary) - assert.NotContains(negotiationErr.Error(), capabilityCredentialValue) + requireChecks.Error(negotiationErr) + assertChecks.Empty(got) + assertChecks.NotContains(negotiationErr.Error(), capabilityResponseCanary) + assertChecks.NotContains(negotiationErr.Error(), capabilityCredentialValue) return } - require.NoError(negotiationErr) - assert.Equal("high", got.ReasoningEffort) - assert.Equal("enabled", got.ReasoningMode) + requireChecks.NoError(negotiationErr) + assertChecks.Equal("high", got.ReasoningEffort) + assertChecks.Equal("enabled", got.ReasoningMode) }) } } @@ -179,14 +255,14 @@ func TestCapabilityNegotiationRetriesClassifiedReasoningMiss(t *testing.T) { t.Run("falls back to a later viable candidate", func(t *testing.T) { newAssert := assert.New - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) var mu sync.Mutex var attempts []capabilityAttempt server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert := newAssert(t) + assertChecks := newAssert(t) var body map[string]any - assert.NoError(json.NewDecoder(r.Body).Decode(&body)) + assertChecks.NoError(json.NewDecoder(r.Body).Decode(&body)) mu.Lock() attempts = append(attempts, capabilityAttempt{path: r.URL.Path, body: body}) mu.Unlock() @@ -201,47 +277,47 @@ func TestCapabilityNegotiationRetriesClassifiedReasoningMiss(t *testing.T) { })) t.Cleanup(server.Close) registry, err := NewDriverRegistry(server.Client(), nil, nil) - require.NoError(err) + requireChecks.NoError(err) candidate := capabilityTestCandidate(ProtocolOpenAIChat, server.URL) candidate.ReasoningEffort = "high" got, negotiationErr := NewCapabilityChecker(registry).Negotiate(t.Context(), candidate, NewCredential(AuthBearer, capabilityCredentialValue)) - require.NoError(negotiationErr) - assert.Equal(OutputModeJSONObject, got.OutputMode) - assert.Equal("max_completion_tokens", got.TokenLimitParameter) - assert.Equal("high", got.ReasoningEffort) - assert.JSONEq(`{"claims":[]}`, string(got.Response.Output)) + requireChecks.NoError(negotiationErr) + assertChecks.Equal(OutputModeJSONObject, got.OutputMode) + assertChecks.Equal("max_completion_tokens", got.TokenLimitParameter) + assertChecks.Equal("high", got.ReasoningEffort) + assertChecks.JSONEq(`{"claims":[]}`, string(got.Response.Output)) mu.Lock() defer mu.Unlock() - require.Len(attempts, 6) + requireChecks.Len(attempts, 6) wantModes := []string{"json_schema", "json_schema", "json_schema", "json_schema", "json_object", "json_object"} wantReasoning := []bool{false, true, false, true, false, true} wantTokenParameters := []string{"max_completion_tokens", "max_completion_tokens", "max_tokens", "max_tokens", "max_completion_tokens", "max_completion_tokens"} for index, attempt := range attempts { - assert.Equal("/chat/completions", attempt.path) + assertChecks.Equal("/chat/completions", attempt.path) format, ok := attempt.body["response_format"].(map[string]any) - require.True(ok) - assert.Equal(wantModes[index], format["type"]) + requireChecks.True(ok) + assertChecks.Equal(wantModes[index], format["type"]) _, reasoning := attempt.body["reasoning_effort"] - assert.Equal(wantReasoning[index], reasoning) + assertChecks.Equal(wantReasoning[index], reasoning) if wantReasoning[index] { - assert.Equal("high", attempt.body["reasoning_effort"]) + assertChecks.Equal("high", attempt.body["reasoning_effort"]) } - assert.Contains(attempt.body, wantTokenParameters[index]) + assertChecks.Contains(attempt.body, wantTokenParameters[index]) if wantTokenParameters[index] == "max_completion_tokens" { - assert.NotContains(attempt.body, "max_tokens") + assertChecks.NotContains(attempt.body, "max_tokens") } else { - assert.NotContains(attempt.body, "max_completion_tokens") + assertChecks.NotContains(attempt.body, "max_completion_tokens") } } }) t.Run("reports the reasoning-specific diagnosis after exhausting every candidate", func(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) var calls atomic.Int32 server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) @@ -259,23 +335,23 @@ func TestCapabilityNegotiationRetriesClassifiedReasoningMiss(t *testing.T) { })) t.Cleanup(server.Close) registry, err := NewDriverRegistry(server.Client(), nil, nil) - require.NoError(err) + requireChecks.NoError(err) candidate := capabilityTestCandidate(ProtocolOpenAIChat, server.URL) candidate.ReasoningEffort = "high" got, negotiationErr := NewCapabilityChecker(registry).Negotiate(t.Context(), candidate, NewCredential(AuthBearer, capabilityCredentialValue)) - require.Error(negotiationErr) - assert.Empty(got) - assert.Equal(int32(12), calls.Load()) - assert.Contains(negotiationErr.Error(), "rejected requested reasoning settings") - assert.NotContains(negotiationErr.Error(), "no supported structured output mode") - assert.NotContains(negotiationErr.Error(), capabilityResponseCanary) + requireChecks.Error(negotiationErr) + assertChecks.Empty(got) + assertChecks.Equal(int32(12), calls.Load()) + assertChecks.Contains(negotiationErr.Error(), "rejected requested reasoning settings") + assertChecks.NotContains(negotiationErr.Error(), "no supported structured output mode") + assertChecks.NotContains(negotiationErr.Error(), capabilityResponseCanary) }) t.Run("keeps the generic diagnosis when every base attempt misses before a reasoning probe", func(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) var calls atomic.Int32 var reasoningProbes atomic.Int32 server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -301,19 +377,19 @@ func TestCapabilityNegotiationRetriesClassifiedReasoningMiss(t *testing.T) { })) t.Cleanup(server.Close) registry, err := NewDriverRegistry(server.Client(), nil, nil) - require.NoError(err) + requireChecks.NoError(err) candidate := capabilityTestCandidate(ProtocolOpenAIChat, server.URL) candidate.ReasoningEffort = "high" got, negotiationErr := NewCapabilityChecker(registry).Negotiate(t.Context(), candidate, NewCredential(AuthBearer, capabilityCredentialValue)) - require.Error(negotiationErr) - assert.Empty(got) - assert.Equal(int32(6), calls.Load()) - assert.Equal(int32(0), reasoningProbes.Load()) - assert.Contains(negotiationErr.Error(), "no supported structured output mode") - assert.NotContains(negotiationErr.Error(), "rejected requested reasoning settings") - assert.NotContains(negotiationErr.Error(), capabilityResponseCanary) + requireChecks.Error(negotiationErr) + assertChecks.Empty(got) + assertChecks.Equal(int32(6), calls.Load()) + assertChecks.Equal(int32(0), reasoningProbes.Load()) + assertChecks.Contains(negotiationErr.Error(), "no supported structured output mode") + assertChecks.NotContains(negotiationErr.Error(), "rejected requested reasoning settings") + assertChecks.NotContains(negotiationErr.Error(), capabilityResponseCanary) }) } @@ -349,8 +425,8 @@ func TestCapabilityNegotiationStopsOnNonCapabilityFailuresAndInvalidOutput(t *te credential: NewCredential(AuthXAPIKey, capabilityCredentialValue)}, } { t.Run(test.name, func(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) var calls atomic.Int32 release := make(chan struct{}) server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -367,7 +443,7 @@ func TestCapabilityNegotiationStopsOnNonCapabilityFailuresAndInvalidOutput(t *te })) t.Cleanup(server.Close) registry, err := NewDriverRegistry(server.Client(), nil, nil) - require.NoError(err) + requireChecks.NoError(err) candidate := capabilityTestCandidate(ProtocolOpenAIChat, server.URL) ctx := t.Context() if test.wait { @@ -380,15 +456,15 @@ func TestCapabilityNegotiationStopsOnNonCapabilityFailuresAndInvalidOutput(t *te if test.wait { close(release) } - require.Error(negotiationErr) - assert.Empty(got) - assert.LessOrEqual(calls.Load(), int32(1)) + requireChecks.Error(negotiationErr) + assertChecks.Empty(got) + assertChecks.LessOrEqual(calls.Load(), int32(1)) if test.wait { - require.ErrorIs(negotiationErr, context.DeadlineExceeded) + requireChecks.ErrorIs(negotiationErr, context.DeadlineExceeded) } - assert.NotContains(negotiationErr.Error(), capabilityResponseCanary) - assert.NotContains(negotiationErr.Error(), capabilityCredentialValue) - assert.NotContains(negotiationErr.Error(), capabilityArchiveCanary) + assertChecks.NotContains(negotiationErr.Error(), capabilityResponseCanary) + assertChecks.NotContains(negotiationErr.Error(), capabilityCredentialValue) + assertChecks.NotContains(negotiationErr.Error(), capabilityArchiveCanary) }) } } @@ -599,38 +675,38 @@ func TestCapabilityDriversRejectParameterizedRepresentationCodesForEveryActiveMo } { t.Run(test.name, func(t *testing.T) { newAssert := assert.New - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) var calls atomic.Int32 server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert := newAssert(t) + assertChecks := newAssert(t) calls.Add(1) - assert.Equal(test.path, r.URL.Path) + assertChecks.Equal(test.path, r.URL.Path) w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(test.errorBody)) })) t.Cleanup(server.Close) registry, err := NewDriverRegistry(server.Client(), nil, nil) - require.NoError(err) + requireChecks.NoError(err) candidate := capabilityTestCandidate(test.protocol, server.URL) candidate.Auth = test.auth profile, err := capabilityProfile(candidate, test.mode, test.tokenParameter, false) - require.NoError(err) + requireChecks.NoError(err) driver, err := registry.capabilityDriver(test.protocol) - require.NoError(err) + requireChecks.NoError(err) prepared, err := driver.Prepare(profile, capabilitySyntheticRequest()) - require.NoError(err) + requireChecks.NoError(err) _, callErr := driver.GeneratePrepared(t.Context(), profile, NewCredential(test.auth, capabilityCredentialValue), prepared) - require.Error(callErr) + requireChecks.Error(callErr) var providerErr *ProviderError - require.ErrorAs(callErr, &providerErr) - assert.Empty(providerErr.Capability) - assert.Equal(int32(1), calls.Load()) + requireChecks.ErrorAs(callErr, &providerErr) + assertChecks.Empty(providerErr.Capability) + assertChecks.Equal(int32(1), calls.Load()) for _, fragment := range []string{test.code, test.parameter, capabilityMessageCanary, capabilityCredentialValue, test.errorBody} { - assert.NotContains(callErr.Error(), fragment) + assertChecks.NotContains(callErr.Error(), fragment) } }) } @@ -662,37 +738,37 @@ func TestCapabilityNegotiationStopsOnParameterizedRepresentationCodeForEveryProt } { t.Run(test.name, func(t *testing.T) { newAssert := assert.New - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) var calls atomic.Int32 server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert := newAssert(t) + assertChecks := newAssert(t) calls.Add(1) - assert.Equal(test.path, r.URL.Path) + assertChecks.Equal(test.path, r.URL.Path) var body map[string]any - assert.NoError(json.NewDecoder(r.Body).Decode(&body)) + assertChecks.NoError(json.NewDecoder(r.Body).Decode(&body)) if test.protocol == ProtocolGoogleGenerateContent { - assert.NotContains(body, "model") + assertChecks.NotContains(body, "model") } else { - assert.Equal("synthetic-model", body["model"]) + assertChecks.Equal("synthetic-model", body["model"]) } w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(test.errorBody)) })) t.Cleanup(server.Close) registry, err := NewDriverRegistry(server.Client(), nil, nil) - require.NoError(err) + requireChecks.NoError(err) candidate := capabilityTestCandidate(test.protocol, server.URL) candidate.Auth = test.auth got, negotiationErr := NewCapabilityChecker(registry).Negotiate(t.Context(), candidate, NewCredential(test.auth, capabilityCredentialValue)) - require.Error(negotiationErr) - assert.Empty(got) - assert.Equal(int32(1), calls.Load()) + requireChecks.Error(negotiationErr) + assertChecks.Empty(got) + assertChecks.Equal(int32(1), calls.Load()) for _, fragment := range []string{test.code, test.parameter, capabilityMessageCanary, capabilityCredentialValue, test.errorBody} { - assert.NotContains(negotiationErr.Error(), fragment) + assertChecks.NotContains(negotiationErr.Error(), fragment) } }) } @@ -716,8 +792,8 @@ func TestCapabilityDriversRejectRepresentationCodesForPromptOnlyAttempts(t *test errorBody: `{"error":{"code":400,"status":"INVALID_ARGUMENT","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"UNSUPPORTED_RESPONSE_FORMAT","domain":"generativelanguage.googleapis.com"}]}}`}, } { t.Run(test.name, func(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) var calls atomic.Int32 attempts := make(chan capabilityAttempt, 1) server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -730,31 +806,31 @@ func TestCapabilityDriversRejectRepresentationCodesForPromptOnlyAttempts(t *test })) t.Cleanup(server.Close) registry, err := NewDriverRegistry(server.Client(), nil, nil) - require.NoError(err) + requireChecks.NoError(err) candidate := capabilityTestCandidate(test.protocol, server.URL) candidate.Auth = test.auth profile, err := capabilityProfile(candidate, OutputModePromptJSON, map[bool]string{true: "max_tokens"}[test.protocol == ProtocolOpenAIChat], false) - require.NoError(err) + requireChecks.NoError(err) driver, err := registry.capabilityDriver(test.protocol) - require.NoError(err) + requireChecks.NoError(err) prepared, err := driver.Prepare(profile, capabilitySyntheticRequest()) - require.NoError(err) + requireChecks.NoError(err) _, callErr := driver.GeneratePrepared(t.Context(), profile, NewCredential(test.auth, capabilityCredentialValue), prepared) - require.Error(callErr) + requireChecks.Error(callErr) var providerErr *ProviderError - require.ErrorAs(callErr, &providerErr) - assert.Empty(providerErr.Capability) - assert.Equal(int32(1), calls.Load()) + requireChecks.ErrorAs(callErr, &providerErr) + assertChecks.Empty(providerErr.Capability) + assertChecks.Equal(int32(1), calls.Load()) attempt := <-attempts - require.NoError(attempt.err) - assert.Equal(test.path, attempt.path) + requireChecks.NoError(attempt.err) + assertChecks.Equal(test.path, attempt.path) if test.protocol == ProtocolGoogleGenerateContent { - assert.NotContains(attempt.body, "model") + assertChecks.NotContains(attempt.body, "model") } else { - assert.Equal("synthetic-model", attempt.body["model"]) + assertChecks.Equal("synthetic-model", attempt.body["model"]) } }) } @@ -787,8 +863,8 @@ func TestCapabilityNegotiationStopsAfterUnclassified400404And422(t *testing.T) { {name: "malformed", status: http.StatusBadRequest, body: `{"error":`}, } { t.Run(test.name, func(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) var calls atomic.Int32 server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { calls.Add(1) @@ -797,15 +873,15 @@ func TestCapabilityNegotiationStopsAfterUnclassified400404And422(t *testing.T) { })) t.Cleanup(server.Close) registry, err := NewDriverRegistry(server.Client(), nil, nil) - require.NoError(err) + requireChecks.NoError(err) got, negotiationErr := NewCapabilityChecker(registry).Negotiate(t.Context(), capabilityTestCandidate(ProtocolOpenAIChat, server.URL), NewCredential(AuthBearer, capabilityCredentialValue)) - require.Error(negotiationErr) - assert.Empty(got) - assert.Equal(int32(1), calls.Load()) - assert.NotContains(negotiationErr.Error(), capabilityResponseCanary) + requireChecks.Error(negotiationErr) + assertChecks.Empty(got) + assertChecks.Equal(int32(1), calls.Load()) + assertChecks.NotContains(negotiationErr.Error(), capabilityResponseCanary) }) } } @@ -826,63 +902,63 @@ func TestCapabilityNegotiationReportsDistinctProviderFailures(t *testing.T) { var statuses []int for _, response := range responses { t.Run(response.name, func(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("X-Request-ID", "capability-repro-request") w.WriteHeader(response.status) _, err := w.Write([]byte(response.body)) - assert.NoError(err) + assertChecks.NoError(err) })) t.Cleanup(server.Close) registry, err := NewDriverRegistry(server.Client(), nil, nil) - require.NoError(err) + requireChecks.NoError(err) _, negotiationErr := NewCapabilityChecker(registry).Negotiate(t.Context(), capabilityTestCandidate(ProtocolOpenAIChat, server.URL), NewCredential(AuthBearer, capabilityCredentialValue)) - require.Error(negotiationErr) + requireChecks.Error(negotiationErr) messages = append(messages, negotiationErr.Error()) var typedErr *NegotiationError - require.ErrorAs(negotiationErr, &typedErr) + requireChecks.ErrorAs(negotiationErr, &typedErr) diagnostics = append(diagnostics, typedErr.Diagnostics) statuses = append(statuses, typedErr.StatusCode) - assert.Equal("capability-repro-request", typedErr.RequestID) - assert.NotContains(typedErr.Error(), "unsupported_parameter") - assert.NotContains(typedErr.Error(), "model_not_found") + assertChecks.Equal("capability-repro-request", typedErr.RequestID) + assertChecks.NotContains(typedErr.Error(), "unsupported_parameter") + assertChecks.NotContains(typedErr.Error(), "model_not_found") }) } - assert := assert.New(t) - require := require.New(t) - require.Len(messages, len(responses)) - assert.Equal([]int{http.StatusNotFound, http.StatusBadRequest}, statuses) - assert.Equal([]ProviderDiagnosticCode{ + assertChecks := assert.New(t) + requireChecks := require.New(t) + requireChecks.Len(messages, len(responses)) + assertChecks.Equal([]int{http.StatusNotFound, http.StatusBadRequest}, statuses) + assertChecks.Equal([]ProviderDiagnosticCode{ ProviderDiagnosticCodeUnclassified, ProviderDiagnosticCodeRejectedField, }, []ProviderDiagnosticCode{diagnostics[0].Code, diagnostics[1].Code}) - assert.Equal(ProviderDiagnosticFieldForeign, diagnostics[1].Field) - assert.NotEqual(messages[0], messages[1]) + assertChecks.Equal(ProviderDiagnosticFieldForeign, diagnostics[1].Field) + assertChecks.NotEqual(messages[0], messages[1]) t.Logf("boundary rejected_field=%q provider_code_absent=%t", ProviderDiagnosticCodeRejectedField, !strings.Contains(messages[1], "unsupported_parameter")) } func TestCapabilityNegotiationPreservesGoogleForeignFieldDiagnostic(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadRequest) _, err := w.Write([]byte(`{"error":{"code":400,"status":"INVALID_ARGUMENT","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"UNSUPPORTED_PARAMETER","domain":"generativelanguage.googleapis.com","metadata":{"parameter":"model"}}]}}`)) - assert.NoError(err) + assertChecks.NoError(err) })) t.Cleanup(server.Close) registry, err := NewDriverRegistry(server.Client(), nil, nil) - require.NoError(err) + requireChecks.NoError(err) _, negotiationErr := NewCapabilityChecker(registry).Negotiate(t.Context(), capabilityTestCandidate(ProtocolGoogleGenerateContent, server.URL), NewCredential(AuthGoogleAPIKey, capabilityCredentialValue)) - require.Error(negotiationErr) + requireChecks.Error(negotiationErr) var typedErr *NegotiationError - require.ErrorAs(negotiationErr, &typedErr) - assert.Equal(ProviderDiagnosticCodeRejectedField, typedErr.Diagnostics.Code) - assert.Equal(ProviderDiagnosticFieldForeign, typedErr.Diagnostics.Field) + requireChecks.ErrorAs(negotiationErr, &typedErr) + assertChecks.Equal(ProviderDiagnosticCodeRejectedField, typedErr.Diagnostics.Code) + assertChecks.Equal(ProviderDiagnosticFieldForeign, typedErr.Diagnostics.Field) } func TestAnthropicForeignRepresentationCodeIsUnclassified(t *testing.T) { @@ -894,36 +970,36 @@ func TestAnthropicForeignRepresentationCodeIsUnclassified(t *testing.T) { } func TestCapabilityNegotiationKeepsClassifiedFallback(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) var attempts []capabilityAttempt server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]any - assert.NoError(json.NewDecoder(r.Body).Decode(&body)) + assertChecks.NoError(json.NewDecoder(r.Body).Decode(&body)) attempts = append(attempts, capabilityAttempt{path: r.URL.Path, body: body}) if len(attempts) == 1 { w.WriteHeader(http.StatusBadRequest) _, err := w.Write([]byte(`{"error":{"type":"invalid_request_error","code":"unsupported_parameter","param":"response_format"}}`)) - assert.NoError(err) + assertChecks.NoError(err) return } _, err := w.Write([]byte(`{"model":"synthetic-model-version","choices":[{"message":{"content":"{\"claims\":[]}"}}]}`)) - assert.NoError(err) + assertChecks.NoError(err) })) t.Cleanup(server.Close) registry, err := NewDriverRegistry(server.Client(), nil, nil) - require.NoError(err) + requireChecks.NoError(err) got, negotiationErr := NewCapabilityChecker(registry).Negotiate(t.Context(), capabilityTestCandidate(ProtocolOpenAIChat, server.URL), NewCredential(AuthBearer, capabilityCredentialValue)) - require.NoError(negotiationErr) - assert.Equal(OutputModeNativeJSONSchema, got.OutputMode) - assert.Equal("max_tokens", got.TokenLimitParameter) - require.Len(attempts, 2) - assert.Equal("json_schema", responseFormatType(t, attempts[0].body)) - assert.Contains(attempts[0].body, "max_completion_tokens") - assert.Equal("json_schema", responseFormatType(t, attempts[1].body)) - assert.Contains(attempts[1].body, "max_tokens") + requireChecks.NoError(negotiationErr) + assertChecks.Equal(OutputModeNativeJSONSchema, got.OutputMode) + assertChecks.Equal("max_tokens", got.TokenLimitParameter) + requireChecks.Len(attempts, 2) + assertChecks.Equal("json_schema", responseFormatType(t, attempts[0].body)) + assertChecks.Contains(attempts[0].body, "max_completion_tokens") + assertChecks.Equal("json_schema", responseFormatType(t, attempts[1].body)) + assertChecks.Contains(attempts[1].body, "max_tokens") } func TestCapabilityNegotiationDiagnosticsUseSafeUnknownClasses(t *testing.T) { @@ -944,97 +1020,97 @@ func TestCapabilityNegotiationDiagnosticsUseSafeUnknownClasses(t *testing.T) { {name: "oversized", body: strings.Repeat("x", (32<<10)+1), wantDiag: unreadableProviderDiagnostics()}, } { t.Run(test.name, func(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("X-Request-ID", "safe-diagnostic-request") w.WriteHeader(http.StatusBadRequest) _, err := w.Write([]byte(test.body)) - assert.NoError(err) + assertChecks.NoError(err) })) t.Cleanup(server.Close) registry, err := NewDriverRegistry(server.Client(), nil, nil) - require.NoError(err) + requireChecks.NoError(err) _, negotiationErr := NewCapabilityChecker(registry).Negotiate(t.Context(), capabilityTestCandidate(ProtocolOpenAIChat, server.URL), NewCredential(AuthBearer, capabilityCredentialValue)) - require.Error(negotiationErr) + requireChecks.Error(negotiationErr) var typedErr *NegotiationError - require.ErrorAs(negotiationErr, &typedErr) - assert.Equal(test.wantDiag, typedErr.Diagnostics) - assert.Equal("safe-diagnostic-request", typedErr.RequestID) - assert.NotContains(typedErr.Error(), "message-fragment-canary-never-report") - assert.NotContains(typedErr.Error(), capabilityCredentialValue) + requireChecks.ErrorAs(negotiationErr, &typedErr) + assertChecks.Equal(test.wantDiag, typedErr.Diagnostics) + assertChecks.Equal("safe-diagnostic-request", typedErr.RequestID) + assertChecks.NotContains(typedErr.Error(), "message-fragment-canary-never-report") + assertChecks.NotContains(typedErr.Error(), capabilityCredentialValue) }) } } func TestCapabilityNegotiationCarriesStageAndAttemptContext(t *testing.T) { t.Run("probe", func(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("X-Request-ID", "probe-request") w.WriteHeader(http.StatusInternalServerError) _, err := w.Write([]byte(capabilityResponseCanary)) - assert.NoError(err) + assertChecks.NoError(err) })) t.Cleanup(server.Close) registry, err := NewDriverRegistry(server.Client(), nil, nil) - require.NoError(err) + requireChecks.NoError(err) _, negotiationErr := NewCapabilityChecker(registry).Negotiate(t.Context(), capabilityTestCandidate(ProtocolOpenAIChat, server.URL), NewCredential(AuthBearer, capabilityCredentialValue)) - require.Error(negotiationErr) + requireChecks.Error(negotiationErr) var typedErr *NegotiationError - require.ErrorAs(negotiationErr, &typedErr) - assert.Equal(NegotiationStageProbe, typedErr.Stage) - assert.Equal(OutputModeNativeJSONSchema, typedErr.OutputMode) - assert.Equal("max_completion_tokens", typedErr.TokenLimitParameter) - assert.Equal(http.StatusInternalServerError, typedErr.StatusCode) - assert.Equal("probe-request", typedErr.RequestID) + requireChecks.ErrorAs(negotiationErr, &typedErr) + assertChecks.Equal(NegotiationStageProbe, typedErr.Stage) + assertChecks.Equal(OutputModeNativeJSONSchema, typedErr.OutputMode) + assertChecks.Equal("max_completion_tokens", typedErr.TokenLimitParameter) + assertChecks.Equal(http.StatusInternalServerError, typedErr.StatusCode) + assertChecks.Equal("probe-request", typedErr.RequestID) }) t.Run("reasoning probe", func(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) var calls atomic.Int32 server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { if calls.Add(1) == 1 { _, err := w.Write([]byte(`{"model":"synthetic-model-version","choices":[{"message":{"content":"{\"claims\":[]}"}}]}`)) - assert.NoError(err) + assertChecks.NoError(err) return } w.Header().Set("X-Request-ID", "reasoning-request") w.WriteHeader(http.StatusInternalServerError) _, err := w.Write([]byte(capabilityResponseCanary)) - assert.NoError(err) + assertChecks.NoError(err) })) t.Cleanup(server.Close) registry, err := NewDriverRegistry(server.Client(), nil, nil) - require.NoError(err) + requireChecks.NoError(err) candidate := capabilityTestCandidate(ProtocolOpenAIChat, server.URL) candidate.ReasoningEffort = "high" _, negotiationErr := NewCapabilityChecker(registry).Negotiate(t.Context(), candidate, NewCredential(AuthBearer, capabilityCredentialValue)) - require.Error(negotiationErr) + requireChecks.Error(negotiationErr) var typedErr *NegotiationError - require.ErrorAs(negotiationErr, &typedErr) - assert.Equal(NegotiationStageReasoningProbe, typedErr.Stage) - assert.True(typedErr.Reasoning) - assert.Equal(OutputModeNativeJSONSchema, typedErr.OutputMode) - assert.Equal("max_completion_tokens", typedErr.TokenLimitParameter) - assert.Equal(http.StatusInternalServerError, typedErr.StatusCode) + requireChecks.ErrorAs(negotiationErr, &typedErr) + assertChecks.Equal(NegotiationStageReasoningProbe, typedErr.Stage) + assertChecks.True(typedErr.Reasoning) + assertChecks.Equal(OutputModeNativeJSONSchema, typedErr.OutputMode) + assertChecks.Equal("max_completion_tokens", typedErr.TokenLimitParameter) + assertChecks.Equal(http.StatusInternalServerError, typedErr.StatusCode) }) t.Run("exhausted", func(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) var calls atomic.Int32 server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) var body map[string]any - assert.NoError(json.NewDecoder(r.Body).Decode(&body)) + assertChecks.NoError(json.NewDecoder(r.Body).Decode(&body)) parameter := "max_completion_tokens" if _, present := body["max_tokens"]; present { parameter = "max_tokens" @@ -1042,75 +1118,75 @@ func TestCapabilityNegotiationCarriesStageAndAttemptContext(t *testing.T) { w.Header().Set("X-Request-ID", "last-attempt") w.WriteHeader(http.StatusUnprocessableEntity) _, err := w.Write([]byte(`{"error":{"type":"invalid_request_error","code":"unsupported_parameter","param":"` + parameter + `"}}`)) - assert.NoError(err) + assertChecks.NoError(err) })) t.Cleanup(server.Close) registry, err := NewDriverRegistry(server.Client(), nil, nil) - require.NoError(err) + requireChecks.NoError(err) _, negotiationErr := NewCapabilityChecker(registry).Negotiate(t.Context(), capabilityTestCandidate(ProtocolOpenAIChat, server.URL), NewCredential(AuthBearer, capabilityCredentialValue)) - require.Error(negotiationErr) + requireChecks.Error(negotiationErr) var typedErr *NegotiationError - require.ErrorAs(negotiationErr, &typedErr) - assert.Equal(NegotiationStageExhausted, typedErr.Stage) - assert.Equal(OutputModePromptJSON, typedErr.OutputMode) - assert.Equal("max_tokens", typedErr.TokenLimitParameter) - assert.Equal(http.StatusUnprocessableEntity, typedErr.StatusCode) - assert.Equal("last-attempt", typedErr.RequestID) - assert.Equal(ProviderDiagnosticCodeRejectedField, typedErr.Diagnostics.Code) - assert.Equal(ProviderDiagnosticFieldTokenLimit, typedErr.Diagnostics.Field) - assert.Equal(int32(6), calls.Load()) + requireChecks.ErrorAs(negotiationErr, &typedErr) + assertChecks.Equal(NegotiationStageExhausted, typedErr.Stage) + assertChecks.Equal(OutputModePromptJSON, typedErr.OutputMode) + assertChecks.Equal("max_tokens", typedErr.TokenLimitParameter) + assertChecks.Equal(http.StatusUnprocessableEntity, typedErr.StatusCode) + assertChecks.Equal("last-attempt", typedErr.RequestID) + assertChecks.Equal(ProviderDiagnosticCodeRejectedField, typedErr.Diagnostics.Code) + assertChecks.Equal(ProviderDiagnosticFieldTokenLimit, typedErr.Diagnostics.Field) + assertChecks.Equal(int32(6), calls.Load()) }) t.Run("settings and driver stages", func(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) registry, err := NewDriverRegistry(nil, nil, nil) - require.NoError(err) + requireChecks.NoError(err) invalid := capabilityTestCandidate(ProtocolAnthropicMessages, "https://example.test") invalid.ReasoningEffort = "high" _, settingsErr := NewCapabilityChecker(registry).Negotiate(t.Context(), invalid, NewCredential(AuthXAPIKey, capabilityCredentialValue)) - require.Error(settingsErr) + requireChecks.Error(settingsErr) var settingsTyped *NegotiationError - require.ErrorAs(settingsErr, &settingsTyped) - assert.Equal(NegotiationStageSettingsInvalid, settingsTyped.Stage) - require.Error(settingsTyped.Unwrap()) - assert.Equal("provider capability negotiation settings are invalid (stage=settings_invalid): "+ + requireChecks.ErrorAs(settingsErr, &settingsTyped) + assertChecks.Equal(NegotiationStageSettingsInvalid, settingsTyped.Stage) + requireChecks.Error(settingsTyped.Unwrap()) + assertChecks.Equal("provider capability negotiation settings are invalid (stage=settings_invalid): "+ settingsTyped.Unwrap().Error(), settingsErr.Error()) unsupported := capabilityTestCandidate(ProtocolCodexAppServer, "https://example.test") _, driverErr := NewCapabilityChecker(registry).Negotiate(t.Context(), unsupported, NewCredential(AuthNone, "")) - require.Error(driverErr) + requireChecks.Error(driverErr) var driverTyped *NegotiationError - require.ErrorAs(driverErr, &driverTyped) - assert.Equal(NegotiationStageDriverUnavailable, driverTyped.Stage) - require.Error(driverTyped.Unwrap()) - assert.Equal("provider capability negotiation is unavailable (stage=driver_unavailable): "+ + requireChecks.ErrorAs(driverErr, &driverTyped) + assertChecks.Equal(NegotiationStageDriverUnavailable, driverTyped.Stage) + requireChecks.Error(driverTyped.Unwrap()) + assertChecks.Equal("provider capability negotiation is unavailable (stage=driver_unavailable): "+ driverTyped.Unwrap().Error(), driverErr.Error()) }) } func TestNegotiationErrorUnwrapsProviderHTTPFailures(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("X-Request-ID", "unwrap-request") w.WriteHeader(http.StatusInternalServerError) })) t.Cleanup(server.Close) registry, err := NewDriverRegistry(server.Client(), nil, nil) - require.NoError(err) + requireChecks.NoError(err) _, negotiationErr := NewCapabilityChecker(registry).Negotiate(t.Context(), capabilityTestCandidate(ProtocolOpenAIChat, server.URL), NewCredential(AuthBearer, capabilityCredentialValue)) - require.Error(negotiationErr) + requireChecks.Error(negotiationErr) var providerErr *ProviderError - require.ErrorAs(negotiationErr, &providerErr) - assert.Equal(http.StatusInternalServerError, providerErr.StatusCode) - assert.Equal("unwrap-request", providerErr.RequestID) + requireChecks.ErrorAs(negotiationErr, &providerErr) + assertChecks.Equal(http.StatusInternalServerError, providerErr.StatusCode) + assertChecks.Equal("unwrap-request", providerErr.RequestID) } func TestCapabilityNegotiationRetriesClassifiedErrorsForEachProtocolFamily(t *testing.T) { @@ -1147,8 +1223,8 @@ func TestCapabilityNegotiationRetriesClassifiedErrorsForEachProtocolFamily(t *te successBody: `{"candidates":[{"content":{"role":"model","parts":[{"text":"{\"claims\":[]}"}]},"finishReason":"STOP"}],"modelVersion":"synthetic-model-version"}`}, } { t.Run(test.name, func(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) var calls atomic.Int32 server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { if calls.Add(1) == 1 { @@ -1160,15 +1236,15 @@ func TestCapabilityNegotiationRetriesClassifiedErrorsForEachProtocolFamily(t *te })) t.Cleanup(server.Close) registry, err := NewDriverRegistry(server.Client(), nil, nil) - require.NoError(err) + requireChecks.NoError(err) candidate := capabilityTestCandidate(test.protocol, server.URL) candidate.Auth = test.auth got, negotiationErr := NewCapabilityChecker(registry).Negotiate(t.Context(), candidate, NewCredential(test.auth, capabilityCredentialValue)) - require.NoError(negotiationErr) - assert.Equal(int32(2), calls.Load()) - assert.JSONEq(`{"claims":[]}`, string(got.Response.Output)) + requireChecks.NoError(negotiationErr) + assertChecks.Equal(int32(2), calls.Load()) + assertChecks.JSONEq(`{"claims":[]}`, string(got.Response.Output)) }) } } @@ -1264,6 +1340,8 @@ func TestCapabilityNegotiationStopsAfterUnclassifiedErrorForEveryProtocol(t *tes t.Run(string(test.protocol), func(t *testing.T) { for index, body := range test.bodies { t.Run(fmt.Sprintf("case-%d", index), func(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) var calls atomic.Int32 attempts := make(chan capabilityAttempt, 1) server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -1276,21 +1354,21 @@ func TestCapabilityNegotiationStopsAfterUnclassifiedErrorForEveryProtocol(t *tes })) t.Cleanup(server.Close) registry, err := NewDriverRegistry(server.Client(), nil, nil) - require.NoError(t, err) + requireChecks.NoError(err) candidate := capabilityTestCandidate(test.protocol, server.URL) candidate.Auth = test.auth got, negotiationErr := NewCapabilityChecker(registry).Negotiate(t.Context(), candidate, NewCredential(test.auth, capabilityCredentialValue)) - require.Error(t, negotiationErr) - assert.Empty(t, got) - assert.Equal(t, int32(1), calls.Load()) + requireChecks.Error(negotiationErr) + assertChecks.Empty(got) + assertChecks.Equal(int32(1), calls.Load()) attempt := <-attempts - require.NoError(t, attempt.err) - assert.Equal(t, test.path, attempt.path) + requireChecks.NoError(attempt.err) + assertChecks.Equal(test.path, attempt.path) if test.protocol == ProtocolGoogleGenerateContent { - assert.NotContains(t, attempt.body, "model") + assertChecks.NotContains(attempt.body, "model") } else { - assert.Equal(t, "synthetic-model", attempt.body["model"]) + assertChecks.Equal("synthetic-model", attempt.body["model"]) } for _, fragment := range []string{ "unsupported", "parameter", "model", "endpoint", "billing", "policy", @@ -1298,7 +1376,7 @@ func TestCapabilityNegotiationStopsAfterUnclassifiedErrorForEveryProtocol(t *tes capabilityParamCanary, capabilityCodeCanary, capabilityAuthCanary, capabilityStatusCanary, capabilityDomainCanary, capabilityBodyCanary, } { - assert.NotContains(t, negotiationErr.Error(), fragment) + assertChecks.NotContains(negotiationErr.Error(), fragment) } }) } @@ -1307,8 +1385,8 @@ func TestCapabilityNegotiationStopsAfterUnclassifiedErrorForEveryProtocol(t *tes } func TestCapabilityNegotiationNeverSwitchesProtocolEndpointOrModel(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) attempts := make(chan capabilityAttempt, 6) server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]any @@ -1323,38 +1401,38 @@ func TestCapabilityNegotiationNeverSwitchesProtocolEndpointOrModel(t *testing.T) })) t.Cleanup(server.Close) registry, err := NewDriverRegistry(server.Client(), nil, nil) - require.NoError(err) + requireChecks.NoError(err) got, err := NewCapabilityChecker(registry).Negotiate(t.Context(), capabilityTestCandidate(ProtocolOpenAIChat, server.URL), NewCredential(AuthBearer, capabilityCredentialValue)) - require.Error(err) - assert.Empty(got) + requireChecks.Error(err) + assertChecks.Empty(got) for range 6 { attempt := <-attempts - require.NoError(attempt.err) - assert.Equal("/chat/completions", attempt.path) - assert.Equal("synthetic-model", attempt.body["model"]) + requireChecks.NoError(attempt.err) + assertChecks.Equal("/chat/completions", attempt.path) + assertChecks.Equal("synthetic-model", attempt.body["model"]) } } func TestCapabilityNegotiationRejectsUnsupportedReasoningBeforeIO(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) var calls atomic.Int32 server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { calls.Add(1) })) t.Cleanup(server.Close) registry, err := NewDriverRegistry(server.Client(), nil, nil) - require.NoError(err) + requireChecks.NoError(err) candidate := capabilityTestCandidate(ProtocolAnthropicMessages, server.URL) candidate.Auth = AuthXAPIKey candidate.ReasoningEffort = "high" _, err = NewCapabilityChecker(registry).Negotiate(t.Context(), candidate, NewCredential(AuthXAPIKey, capabilityCredentialValue)) - require.Error(err) - assert.Equal(int32(0), calls.Load()) - assert.NotContains(err.Error(), capabilityCredentialValue) + requireChecks.Error(err) + assertChecks.Equal(int32(0), calls.Load()) + assertChecks.NotContains(err.Error(), capabilityCredentialValue) } func TestCapabilityNegotiationRejectsMissingRegistryWithoutPanic(t *testing.T) { @@ -1389,24 +1467,24 @@ func capabilityTestCandidate(protocol Protocol, endpoint string) ProviderConfig // use. func TestCapabilityNegotiationExercisesRealExtractionSchema(t *testing.T) { newAssert := assert.New - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) extractionSchema := ExtractionJSONSchema() var mu sync.Mutex var attempts []capabilityAttempt server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert := newAssert(t) + assertChecks := newAssert(t) var body map[string]any - assert.NoError(json.NewDecoder(r.Body).Decode(&body)) + assertChecks.NoError(json.NewDecoder(r.Body).Decode(&body)) mu.Lock() attempts = append(attempts, capabilityAttempt{path: r.URL.Path, body: body}) mu.Unlock() format, native := body["response_format"].(map[string]any) if native && format["type"] == "json_schema" { jsonSchema, ok := format["json_schema"].(map[string]any) - assert.True(ok) + assertChecks.True(ok) encoded, err := json.Marshal(jsonSchema["schema"]) - assert.NoError(err) + assertChecks.NoError(err) if capabilitySameJSON(extractionSchema, encoded) { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"error":{"type":"invalid_request_error","code":"unsupported_parameter","param":"response_format","message":"` + capabilityResponseCanary + `"}}`)) @@ -1419,44 +1497,44 @@ func TestCapabilityNegotiationExercisesRealExtractionSchema(t *testing.T) { })) t.Cleanup(server.Close) registry, err := NewDriverRegistry(server.Client(), nil, nil) - require.NoError(err) + requireChecks.NoError(err) got, negotiationErr := NewCapabilityChecker(registry).Negotiate(t.Context(), capabilityTestCandidate(ProtocolOpenAIChat, server.URL), NewCredential(AuthBearer, capabilityCredentialValue)) - require.NoError(negotiationErr) - assert.Equal(OutputModeJSONObject, got.OutputMode) - assert.Equal("max_completion_tokens", got.TokenLimitParameter) - assert.JSONEq(`{"claims":[]}`, string(got.Response.Output)) + requireChecks.NoError(negotiationErr) + assertChecks.Equal(OutputModeJSONObject, got.OutputMode) + assertChecks.Equal("max_completion_tokens", got.TokenLimitParameter) + assertChecks.JSONEq(`{"claims":[]}`, string(got.Response.Output)) mu.Lock() defer mu.Unlock() - require.Len(attempts, 3) + requireChecks.Len(attempts, 3) for _, attempt := range attempts[:2] { - assert.Equal("json_schema", responseFormatType(t, attempt.body)) + assertChecks.Equal("json_schema", responseFormatType(t, attempt.body)) native, ok := attempt.body["response_format"].(map[string]any)["json_schema"].(map[string]any) - require.True(ok) - assert.Equal(ExtractionSchemaName, native["name"]) + requireChecks.True(ok) + assertChecks.Equal(ExtractionSchemaName, native["name"]) encoded, marshalErr := json.Marshal(native["schema"]) - require.NoError(marshalErr) - assert.JSONEq(string(extractionSchema), string(encoded)) + requireChecks.NoError(marshalErr) + assertChecks.JSONEq(string(extractionSchema), string(encoded)) } - assert.Equal("json_object", responseFormatType(t, attempts[2].body)) + assertChecks.Equal("json_object", responseFormatType(t, attempts[2].body)) messages, ok := attempts[2].body["messages"].([]any) - require.True(ok) + requireChecks.True(ok) system, ok := messages[0].(map[string]any) - require.True(ok) - assert.Equal(jsonObjectInstruction+string(extractionSchema), system["content"]) + requireChecks.True(ok) + assertChecks.Equal(jsonObjectInstruction+string(extractionSchema), system["content"]) } func TestCapabilitySyntheticRequestUsesFrozenExtractionSchema(t *testing.T) { - assert := assert.New(t) + assertChecks := assert.New(t) request := capabilitySyntheticRequest() - assert.Equal("provider-check", request.ProgramID) - assert.Equal(ExtractionSchemaName, request.SchemaName) - assert.JSONEq(string(ExtractionJSONSchema()), string(request.JSONSchema)) - assert.Empty(request.Sources) - assert.False(request.ContainsSensitive) + assertChecks.Equal("provider-check", request.ProgramID) + assertChecks.Equal(ExtractionSchemaName, request.SchemaName) + assertChecks.JSONEq(string(ExtractionJSONSchema()), string(request.JSONSchema)) + assertChecks.Empty(request.Sources) + assertChecks.False(request.ContainsSensitive) } func capabilitySameJSON(left, right []byte) bool { diff --git a/internal/peoplesweep/codex_app_server.go b/internal/peoplesweep/codex_app_server.go index e6cdb21a8..4c29a21b8 100644 --- a/internal/peoplesweep/codex_app_server.go +++ b/internal/peoplesweep/codex_app_server.go @@ -22,9 +22,8 @@ import ( ) const ( - codexPacketFilename = "packet.json" - codexPreparedComponentCount = 5 - codexFixedUserInput = "Read packet.json and return only JSON matching the supplied output schema." + codexPreparedComponentCount = 6 + codexFixedUserInput = "Return only JSON matching the supplied output schema.\n\n" codexModelListLimit = 100 codexThreadIDReservationBytes = 128 codexDisableFlag = "--disable" @@ -32,6 +31,7 @@ const ( ) var codexReservedThreadID = strings.Repeat("t", codexThreadIDReservationBytes) +var codexInitializedNotificationFrame = []byte("{\"method\":\"initialized\"}\n") var codexAppServerArgs = []string{ "app-server", "--stdio", "--strict-config", @@ -59,9 +59,9 @@ type CodexModel struct { // CodexAppServerDriver runs the attested Codex executable through the // bounded App Server v2 stdio protocol. type CodexAppServerDriver struct { - Config ProviderConfig - Commands CommandStarter - Isolation CodexIsolationGate + Config ProviderConfig + launcher CodexLauncher + authHome string } // NewCodexAppServerDriver validates the immutable launch dependencies. The @@ -71,6 +71,18 @@ func NewCodexAppServerDriver( cfg ProviderConfig, commands CommandStarter, isolation CodexIsolationGate, +) (*CodexAppServerDriver, error) { + return NewCodexAppServerDriverWithAuthHome(cfg, commands, isolation, "") +} + +// NewCodexAppServerDriverWithAuthHome accepts only a daemon-selected credential +// directory. An empty value runs without credentials; ambient CODEX_HOME is never +// consulted. The launcher stages only auth.json into its disposable work root. +func NewCodexAppServerDriverWithAuthHome( + cfg ProviderConfig, + commands CommandStarter, + isolation CodexIsolationGate, + authHome string, ) (*CodexAppServerDriver, error) { validation := Config{ Enabled: true, Provider: ProviderSelection{Name: "runtime"}, @@ -93,9 +105,11 @@ func NewCodexAppServerDriver( if isolation == nil { return nil, errors.New("codex app-server isolation gate is required") } - return &CodexAppServerDriver{ - Config: provider, Commands: commands, Isolation: isolation, - }, nil + launcher := codexBoundLauncher{gate: isolation, starter: commands} + if authHome != "" { + launcher.proxy = defaultCodexServiceProxy() + } + return &CodexAppServerDriver{Config: provider, launcher: launcher, authHome: authHome}, nil } type codexInitializeParams struct { @@ -120,17 +134,11 @@ type codexReadOnlySandboxPolicy struct { } type codexThreadStartParams struct { - Model string `json:"model"` - Effort string `json:"effort"` - Ephemeral bool `json:"ephemeral"` - CWD string `json:"cwd"` - RuntimeWorkspaceRoots []string `json:"runtimeWorkspaceRoots"` - SelectedCapabilityRoots []string `json:"selectedCapabilityRoots"` - DynamicTools []any `json:"dynamicTools"` - Environments []any `json:"environments"` - ApprovalPolicy string `json:"approvalPolicy"` - Sandbox string `json:"sandbox"` - SandboxPolicy codexReadOnlySandboxPolicy `json:"sandboxPolicy"` + Model string `json:"model"` + Ephemeral bool `json:"ephemeral"` + CWD string `json:"cwd"` + ApprovalPolicy string `json:"approvalPolicy"` + Sandbox string `json:"sandbox"` } type codexTurnStartParams struct { @@ -140,7 +148,6 @@ type codexTurnStartParams struct { Effort string `json:"effort"` CWD string `json:"cwd"` ApprovalPolicy string `json:"approvalPolicy"` - Sandbox string `json:"sandbox"` SandboxPolicy codexReadOnlySandboxPolicy `json:"sandboxPolicy"` OutputSchema jsontext.Value `json:"outputSchema"` } @@ -150,13 +157,13 @@ type codexTextInput struct { Text string `json:"text"` } -func codexInitializeRequest(id int64) codexRPCRequest { +func codexInitializeRequest() codexRPCRequest { params := codexInitializeParams{} params.ClientInfo.Name = "msgvault" params.ClientInfo.Title = "msgvault" params.ClientInfo.Version = "1" params.Capabilities.ExperimentalAPI = true - return codexRPCRequest{Method: "initialize", ID: id, Params: params} + return codexRPCRequest{Method: "initialize", ID: 1, Params: params} } func codexModelListRequest(id int64) codexRPCRequest { @@ -167,10 +174,8 @@ func codexModelListRequest(id int64) codexRPCRequest { func codexThreadStartRequest(id int64, profile ProviderProfile) codexRPCRequest { return codexRPCRequest{Method: "thread/start", ID: id, Params: codexThreadStartParams{ - Model: profile.Model, Effort: profile.ReasoningEffort, Ephemeral: true, CWD: ".", - RuntimeWorkspaceRoots: []string{"."}, SelectedCapabilityRoots: []string{}, - DynamicTools: []any{}, Environments: []any{}, ApprovalPolicy: "never", Sandbox: "read-only", - SandboxPolicy: codexReadOnlySandboxPolicy{Type: "readOnly", NetworkAccess: false}, + Model: profile.Model, Ephemeral: true, CWD: "/work", + ApprovalPolicy: "never", Sandbox: "read-only", }} } @@ -182,15 +187,15 @@ func codexTurnStartRequest( ) codexRPCRequest { return codexRPCRequest{Method: "turn/start", ID: id, Params: codexTurnStartParams{ ThreadID: threadID, - Input: []codexTextInput{{Type: "text", Text: codexFixedUserInput}}, - Model: profile.Model, Effort: profile.ReasoningEffort, CWD: ".", - ApprovalPolicy: "never", Sandbox: "read-only", - SandboxPolicy: codexReadOnlySandboxPolicy{Type: "readOnly", NetworkAccess: false}, - OutputSchema: slices.Clone(request.JSONSchema), + Input: []codexTextInput{{Type: "text", Text: codexFixedUserInput + request.InputText}}, + Model: profile.Model, Effort: profile.ReasoningEffort, CWD: "/work", + ApprovalPolicy: "never", + SandboxPolicy: codexReadOnlySandboxPolicy{Type: "readOnly", NetworkAccess: false}, + OutputSchema: slices.Clone(request.JSONSchema), }} } -// Prepare constructs the packet and four exact outbound request frames. +// Prepare constructs the packet and five exact outbound protocol frames. // Each component is independently length-prefixed for unambiguous reservation. func (t *CodexAppServerDriver) Prepare( profile ProviderProfile, @@ -200,19 +205,22 @@ func (t *CodexAppServerDriver) Prepare( return PreparedStructuredRequest{}, err } frames := []codexRPCRequest{ - codexInitializeRequest(1), + codexInitializeRequest(), codexModelListRequest(2), codexThreadStartRequest(3, profile), codexTurnStartRequest(4, profile, request, codexReservedThreadID), } components := make([][]byte, 0, codexPreparedComponentCount) components = append(components, []byte(request.InputText)) - for _, frame := range frames { + for index, frame := range frames { encoded, err := json.Marshal(frame, json.Deterministic(true)) if err != nil { return PreparedStructuredRequest{}, errors.New("encode codex app-server request") } components = append(components, append(encoded, '\n')) + if index == 0 { + components = append(components, slices.Clone(codexInitializedNotificationFrame)) + } } wire, err := encodeCodexPreparedComponents(components) if err != nil { @@ -301,6 +309,11 @@ func (t *CodexAppServerDriver) GeneratePrepared( if !bytes.Equal(components[0], []byte(prepared.Request().InputText)) { return DriverResponse{}, errors.New("prepared codex app-server packet does not match request") } + releaseAuth, err := lockCodexAuthOperation(ctx, t.authHome) + if err != nil { + return DriverResponse{}, err + } + defer releaseAuth() attestation, err := t.attest(ctx) if err != nil { @@ -312,42 +325,28 @@ func (t *CodexAppServerDriver) GeneratePrepared( retErr = closeErr } }() - packetRoot, err := os.MkdirTemp("", "msgvault-codex-packet-") - if err != nil { - return DriverResponse{}, errors.New("create codex packet root") - } - defer func() { - if cleanupErr := os.RemoveAll(packetRoot); cleanupErr != nil && retErr == nil { - response = DriverResponse{} - retErr = errors.New("remove codex packet root") - } - }() - packetPath := filepath.Join(packetRoot, codexPacketFilename) - if err := os.WriteFile(packetPath, components[0], 0o400); err != nil { - return DriverResponse{}, errors.New("write codex packet") - } - if err := os.Chmod(packetPath, 0o400); err != nil { - return DriverResponse{}, errors.New("protect codex packet") - } - - process, err := t.startAttested(ctx, attestation, packetRoot) + process, err := t.startAttested(ctx, attestation) if err != nil { return DriverResponse{}, err } client := &CodexRPCClient{Process: process} defer func() { cleanupErr := finishCodexProcess(ctx, process, client, retErr != nil) - if cleanupErr != nil && retErr == nil { - retErr = cleanupErr + if retErr != nil || cleanupErr != nil { + response.CandidateJSON = nil } + retErr = errors.Join(retErr, cleanupErr) }() var initialized map[string]any if err := client.callPrepared(ctx, components[1], &initialized); err != nil { return DriverResponse{}, err } + if err := client.notifyPreparedInitialized(ctx, components[2]); err != nil { + return DriverResponse{}, err + } var catalog codexModelListResult - if err := client.callPrepared(ctx, components[2], &catalog); err != nil { + if err := client.callPrepared(ctx, components[3], &catalog); err != nil { return DriverResponse{}, err } if catalog.NextCursor != nil || len(catalog.Data) > codexModelListLimit { @@ -359,13 +358,13 @@ func (t *CodexAppServerDriver) GeneratePrepared( } var threadResult codexThreadStartResult - if err := client.callPrepared(ctx, components[3], &threadResult); err != nil { + if err := client.callPrepared(ctx, components[4], &threadResult); err != nil { return DriverResponse{}, err } if !safeProviderMetadata(threadResult.Thread.ID) || !threadResult.Thread.Ephemeral { return DriverResponse{}, fmt.Errorf("%w: Codex returned an invalid ephemeral thread", ErrInvalidStructuredOutput) } - turnFrame, err := rewritePreparedTurnThreadID(components[4], threadResult.Thread.ID) + turnFrame, err := rewritePreparedTurnThreadID(components[5], threadResult.Thread.ID) if err != nil { return DriverResponse{}, err } @@ -421,11 +420,16 @@ func rewritePreparedTurnThreadID(frame []byte, actual string) ([]byte, error) { if params.ThreadID != codexReservedThreadID { return nil, errors.New("prepared codex turn thread-ID slot is invalid") } - reserved := []byte(codexReservedThreadID) - if bytes.Count(frame, reserved) != 1 { - return nil, errors.New("prepared codex turn thread-ID slot is ambiguous") + preparedFrame, err := json.Marshal(codexRPCRequest{Method: "turn/start", ID: 4, Params: params}, json.Deterministic(true)) + if err != nil || !bytes.Equal(frame, append(preparedFrame, '\n')) { + return nil, errors.New("prepared codex turn frame is not canonical") } - actualFrame := bytes.Replace(frame, reserved, []byte(actual), 1) + params.ThreadID = actual + actualFrame, err := json.Marshal(codexRPCRequest{Method: "turn/start", ID: 4, Params: params}, json.Deterministic(true)) + if err != nil { + return nil, errors.New("encode codex turn with actual thread ID") + } + actualFrame = append(actualFrame, '\n') if len(actualFrame) > len(frame) { return nil, errors.New("codex turn thread-ID substitution exceeds its reservation") } @@ -546,7 +550,7 @@ func readCodexFinal( Total *struct { InputTokens *int64 `json:"inputTokens"` OutputTokens *int64 `json:"outputTokens"` - } `json:"totalTokenUsage"` + } `json:"total"` } `json:"tokenUsage"` } if decodeSingleJSON(params, &event) != nil || event.ThreadID != threadID || @@ -598,11 +602,18 @@ func validateCodexFinal(request StructuredRequest, final jsontext.Value) error { // StartDeviceLogin keeps the app-server session alive until the device-code // flow completes. present receives only the bounded public ceremony fields. +// New credentials require the dedicated enrollment client. When credentials +// were staged from authHome, cleanup permits only a refresh for the same account. func (t *CodexAppServerDriver) StartDeviceLogin( ctx context.Context, present func(DeviceLogin) error, ) (retErr error) { operationCtx, cancel := context.WithTimeout(ctx, t.Config.RequestTimeout) defer cancel() + releaseAuth, err := lockCodexAuthOperation(operationCtx, t.authHome) + if err != nil { + return err + } + defer releaseAuth() return t.withProcessDeviceLogin(operationCtx, present) } @@ -618,8 +629,15 @@ func (t *CodexAppServerDriver) withProcessDeviceLogin( } client := &CodexRPCClient{Process: process} defer func() { retErr = cleanup(client, retErr) }() + return runCodexDeviceLogin(ctx, client, present) +} + +func runCodexDeviceLogin(ctx context.Context, client *CodexRPCClient, present func(DeviceLogin) error) error { var initialized map[string]any - if err := client.Call(ctx, "initialize", codexInitializeRequest(1).Params, &initialized); err != nil { + if err := client.Call(ctx, "initialize", codexInitializeRequest().Params, &initialized); err != nil { + return err + } + if err := client.Notify(ctx, "initialized", nil); err != nil { return err } var result struct { @@ -696,6 +714,11 @@ func (t *CodexAppServerDriver) ListModels(ctx context.Context) (models []CodexMo operationCtx, cancel := context.WithTimeout(ctx, t.Config.RequestTimeout) defer cancel() ctx = operationCtx + releaseAuth, err := lockCodexAuthOperation(ctx, t.authHome) + if err != nil { + return nil, err + } + defer releaseAuth() process, cleanup, err := t.launchEmpty(ctx) if err != nil { @@ -704,7 +727,10 @@ func (t *CodexAppServerDriver) ListModels(ctx context.Context) (models []CodexMo client := &CodexRPCClient{Process: process} defer func() { retErr = cleanup(client, retErr) }() var initialized map[string]any - if err := client.Call(ctx, "initialize", codexInitializeRequest(1).Params, &initialized); err != nil { + if err := client.Call(ctx, "initialize", codexInitializeRequest().Params, &initialized); err != nil { + return nil, err + } + if err := client.Notify(ctx, "initialized", nil); err != nil { return nil, err } var result codexModelListResult @@ -726,7 +752,7 @@ func (t *CodexAppServerDriver) ListModels(ctx context.Context) (models []CodexMo } func (t *CodexAppServerDriver) attest(ctx context.Context) (CodexAttestation, error) { - attestation, err := t.Isolation.Verify(ctx, t.Config.Executable, t.Config.ExecutionBoundary) + attestation, err := t.launcher.Verify(ctx, t.Config.Executable) if err != nil { _ = attestation.Close() return CodexAttestation{}, fmt.Errorf("verify codex app-server isolation: %w", err) @@ -746,19 +772,13 @@ func (t *CodexAppServerDriver) attest(ctx context.Context) (CodexAttestation, er func (t *CodexAppServerDriver) startAttested( ctx context.Context, attestation CodexAttestation, - dir string, ) (RPCProcess, error) { if err := ctx.Err(); err != nil { return nil, err } - if err := t.Isolation.ReverifyForLaunch(attestation); err != nil { - return nil, fmt.Errorf("reverify codex app-server isolation: %w", err) - } - process, err := t.Commands.Start( - ctx, attestation.VerifiedExecutable(), slices.Clone(codexAppServerArgs), scrubCodexEnvironment(os.Environ()), dir, - ) + process, err := t.launcher.Start(ctx, attestation, t.authHome) if err != nil { - return nil, errors.New("start codex app-server process") + return nil, fmt.Errorf("start codex app-server process: %w", err) } return process, nil } @@ -770,30 +790,20 @@ func (t *CodexAppServerDriver) launchEmpty( if err != nil { return nil, nil, err } - dir, err := os.MkdirTemp("", "msgvault-codex-operation-") - if err != nil { - _ = attestation.Close() - return nil, nil, errors.New("create codex operation root") - } - process, err := t.startAttested(ctx, attestation, dir) + process, err := t.startAttested(ctx, attestation) if err != nil { - _ = os.RemoveAll(dir) _ = attestation.Close() return nil, nil, err } cleanup := func(client *CodexRPCClient, operationErr error) error { processErr := finishCodexProcess(ctx, process, client, operationErr != nil) - removeErr := os.RemoveAll(dir) attestationErr := attestation.Close() if operationErr != nil { - return operationErr + return errors.Join(operationErr, processErr, attestationErr) } if processErr != nil { return processErr } - if removeErr != nil { - return errors.New("remove codex operation root") - } if attestationErr != nil { return attestationErr } @@ -813,13 +823,19 @@ func finishCodexProcess( } var closeErr error if stdin := process.Stdin(); stdin != nil { - closeErr = stdin.Close() + if err := stdin.Close(); err != nil && !errors.Is(err, os.ErrClosed) && !errors.Is(err, io.ErrClosedPipe) { + closeErr = err + } } waitDone := make(chan error, 1) go func() { waitDone <- process.Wait() }() var waitErr error var cleanupContextErr error waited := false + var childExited <-chan struct{} + if owned, ok := process.(*codexOwnedProcess); ok { + childExited = owned.childExited + } killAttempted := false killSucceeded := false killAlreadyFinished := false @@ -841,6 +857,11 @@ func finishCodexProcess( select { case waitErr = <-waitDone: waited = true + case <-childExited: + // The child is gone. Allow the bounded credential copy-back to + // finish before cleanup; the process-exit grace no longer applies. + waitErr = <-waitDone + waited = true case <-ctx.Done(): cleanupContextErr = ctx.Err() kill() @@ -879,12 +900,12 @@ func finishCodexProcess( stderrJoined, stderrErr = client.waitForStderr(ctx, codexProcessExitGrace) } if stdout := process.Stdout(); stdout != nil { - if err := stdout.Close(); err != nil && closeErr == nil { + if err := stdout.Close(); err != nil && !errors.Is(err, os.ErrClosed) && !errors.Is(err, io.ErrClosedPipe) && closeErr == nil { closeErr = err } } if stderr := process.Stderr(); stderr != nil { - if err := stderr.Close(); err != nil && closeErr == nil { + if err := stderr.Close(); err != nil && !errors.Is(err, os.ErrClosed) && !errors.Is(err, io.ErrClosedPipe) && closeErr == nil { closeErr = err } } @@ -898,7 +919,20 @@ func finishCodexProcess( if killSucceeded && errors.Is(stderrErr, errCodexStderrRead) { stderrErr = nil } + if owned, ok := process.(*codexOwnedProcess); ok { + if err := owned.cleanup(); err != nil { + return errors.New("remove codex app-server work root") + } + } + for _, authErr := range []error{ErrCodexAuthAccountChanged, ErrCodexAuthSourceChanged, ErrCodexAuthRefreshUnsafe} { + if errors.Is(waitErr, authErr) { + return authErr + } + } if forceKill { + if waitAbandoned { + return errors.New("codex app-server process termination failed") + } return nil } if cleanupContextErr != nil { @@ -947,11 +981,6 @@ func scrubCodexEnvironment(environment []string) []string { type execCommandStarter struct{} -// NewCodexCommandStarter returns the production os/exec-backed process -// boundary. The isolation gate still controls which absolute executable may be -// passed to it. -func NewCodexCommandStarter() CommandStarter { return execCommandStarter{} } - func (execCommandStarter) Start( ctx context.Context, executable CodexExecutable, diff --git a/internal/peoplesweep/codex_app_server_test.go b/internal/peoplesweep/codex_app_server_test.go index 8751cb76c..e42a7fc57 100644 --- a/internal/peoplesweep/codex_app_server_test.go +++ b/internal/peoplesweep/codex_app_server_test.go @@ -4,6 +4,8 @@ import ( "bufio" "bytes" "context" + "crypto/sha256" + "encoding/base64" "encoding/binary" "encoding/json" "errors" @@ -83,10 +85,22 @@ type recordingCodexStarter struct { scripts []func(*bufio.Reader, io.Writer, io.Writer) error records []codexStartRecord starts atomic.Int64 + proxyStarts atomic.Int64 inspect func(string) configureProcess func(*pipeRPCProcess) } +func (s *recordingCodexStarter) StartWithProxy( + ctx context.Context, executable peoplesweep.CodexExecutable, args, env []string, dir, socketPath string, +) (peoplesweep.RPCProcess, error) { + s.proxyStarts.Add(1) + info, err := os.Lstat(socketPath) + require.NoError(s.t, err) + assert.NotZero(s.t, info.Mode()&os.ModeSocket) + assert.Equal(s.t, filepath.Join(dir, ".proxy.sock"), socketPath) + return s.Start(ctx, executable, args, env, dir) +} + func (s *recordingCodexStarter) Start( _ context.Context, executable peoplesweep.CodexExecutable, @@ -154,8 +168,6 @@ type codexTranscript struct { mu sync.Mutex methods []string frames [][]byte - packet []byte - packetWrite error rootEntries []string } @@ -180,7 +192,8 @@ func successfulCodexScript( ) func(*bufio.Reader, io.Writer, io.Writer) error { t.Helper() return func(reader *bufio.Reader, stdout, _ io.Writer) error { - for id, wantMethod := range []string{"initialize", "model/list", "thread/start", "turn/start"} { + requestID := int64(0) + for _, wantMethod := range []string{"initialize", "initialized", "model/list", "thread/start", "turn/start"} { line, err := reader.ReadBytes('\n') if err != nil { return fmt.Errorf("read codex request frame: %w", err) @@ -193,10 +206,17 @@ func successfulCodexScript( if err := json.Unmarshal(line, &envelope); err != nil { return err } - if envelope.Method != wantMethod || envelope.ID != int64(id+1) { + if wantMethod != "initialized" { + requestID++ + } + if envelope.Method != wantMethod || + (wantMethod == "initialized" && envelope.ID != 0) || + (wantMethod != "initialized" && envelope.ID != requestID) { return errors.New("unexpected prepared request order") } switch wantMethod { + case "initialized": + continue case "initialize": err = writeRPCFrame(stdout, map[string]any{"id": envelope.ID, "result": map[string]any{}}) case "model/list": @@ -229,10 +249,7 @@ func successfulCodexScript( }}); err != nil { return err } - if err := writeRPCFrame(stdout, map[string]any{"method": "thread/tokenUsage/updated", "params": map[string]any{ - "threadId": "thr_test", "turnId": "turn_test", - "tokenUsage": map[string]any{"totalTokenUsage": map[string]any{"inputTokens": 21, "outputTokens": 4}}, - }}); err != nil { + if err := writeCodexUsageEvent(stdout, 21, 4); err != nil { return err } return writeRPCFrame(stdout, map[string]any{"method": "turn/completed", "params": map[string]any{ @@ -267,6 +284,16 @@ func codexTurnEventScript( if err := writeRPCFrame(stdout, map[string]any{"id": id + 1, "result": result}); err != nil { return err } + if id == 0 { + line, err := reader.ReadBytes('\n') + if err != nil { + return fmt.Errorf("read codex initialized notification: %w", err) + } + transcript.record(line) + if !bytes.Contains(line, []byte(`"method":"initialized"`)) { + return errors.New("missing codex initialized notification") + } + } } line, err := reader.ReadBytes('\n') if err != nil { @@ -291,11 +318,13 @@ func codexTurnEventScript( } func writeCodexUsageEvent(w io.Writer, inputTokens, outputTokens int64) error { + breakdown := map[string]any{ + "inputTokens": inputTokens, "outputTokens": outputTokens, "cachedInputTokens": 0, + "reasoningOutputTokens": 0, "totalTokens": inputTokens + outputTokens, + } return writeRPCFrame(w, map[string]any{"method": "thread/tokenUsage/updated", "params": map[string]any{ "threadId": "thr_test", "turnId": "turn_test", - "tokenUsage": map[string]any{"totalTokenUsage": map[string]any{ - "inputTokens": inputTokens, "outputTokens": outputTokens, - }}, + "tokenUsage": map[string]any{"total": breakdown, "last": breakdown}, }}) } @@ -322,17 +351,8 @@ func newSuccessfulCodexTransport( successfulCodexScript(t, transcript, "gpt-test", []string{"low", "high"}, nil, finalJSON), }} starter.inspect = func(dir string) { - packetPath := filepath.Join(dir, "packet.json") - packet, err := os.ReadFile(packetPath) - require.NoError(t, err) - writeHandle, writeErr := os.OpenFile(packetPath, os.O_WRONLY, 0) - if writeHandle != nil { - require.NoError(t, writeHandle.Close()) - } entries, err := os.ReadDir(dir) require.NoError(t, err) - transcript.packet = packet - transcript.packetWrite = writeErr for _, entry := range entries { transcript.rootEntries = append(transcript.rootEntries, entry.Name()) } @@ -343,6 +363,211 @@ func newSuccessfulCodexTransport( return transport, starter, gate, transcript } +func TestCodexRegistryUsesOnlyExplicitAuthHome(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + if runtime.GOOS == "windows" { + t.Skip("codex auth home permission gates require Unix permission bits") + } + authHome := t.TempDir() + requireChecks.NoError(os.Chmod(authHome, 0o700)) + requireChecks.NoError(os.WriteFile(filepath.Join(authHome, "auth.json"), []byte(`{"synthetic":true}`), 0o600)) + requireChecks.NoError(os.WriteFile(filepath.Join(authHome, "unrelated.txt"), []byte("SYNTHETIC_UNRELATED"), 0o600)) + ambientHome := t.TempDir() + requireChecks.NoError(os.WriteFile(filepath.Join(ambientHome, "auth.json"), []byte("SYNTHETIC_AMBIENT"), 0o600)) + t.Setenv("CODEX_HOME", ambientHome) + starter := &recordingCodexStarter{t: t, scripts: []func(*bufio.Reader, io.Writer, io.Writer) error{ + successfulCodexScript(t, &codexTranscript{}, "gpt-test", []string{"low", "high"}, nil, `{"claims":[]}`), + }} + starter.inspect = func(workRoot string) { + contents, err := os.ReadFile(filepath.Join(workRoot, ".codex", "auth.json")) + require.NoError(t, err) + assert.Equal(t, `{"synthetic":true}`, string(contents)) + assert.NoFileExists(t, filepath.Join(workRoot, "unrelated.txt")) + assert.NoFileExists(t, filepath.Join(workRoot, ".codex", "unrelated.txt")) + } + registry, err := peoplesweep.NewDriverRegistryWithCodexAuthHome(nil, starter, &recordingCodexGate{}, authHome) + requireChecks.NoError(err) + driver, err := registry.Driver(peoplesweep.ProtocolCodexAppServer, codexTestConfig()) + requireChecks.NoError(err) + profile := codexTestProfile(t) + prepared, err := driver.Prepare(profile, codexTestRequest()) + requireChecks.NoError(err) + _, err = driver.GeneratePrepared(t.Context(), profile, peoplesweep.Credential{}, prepared) + requireChecks.NoError(err) + requireChecks.Len(starter.records, 1) + assertChecks.Equal(int64(1), starter.proxyStarts.Load()) + assertChecks.Empty(starter.records[0].env) + assertChecks.NoDirExists(starter.records[0].dir) + contents, err := os.ReadFile(filepath.Join(authHome, "auth.json")) + requireChecks.NoError(err) + assertChecks.Equal(`{"synthetic":true}`, string(contents)) +} + +func syntheticCodexAuth(user, workspace, refresh string) []byte { + claims := fmt.Sprintf(`{"https://api.openai.com/auth":{"chatgpt_user_id":%q,"chatgpt_account_id":%q}}`, user, workspace) + idToken := "header." + base64.RawURLEncoding.EncodeToString([]byte(claims)) + ".signature" + return []byte(fmt.Sprintf(`{"auth_mode":"chatgpt","tokens":{"id_token":%q,"access_token":"synthetic-access","refresh_token":%q,"account_id":%q}}`, idToken, refresh, workspace)) +} + +func TestCodexInferenceCopiesBackRefreshForSameAccount(t *testing.T) { + for _, tc := range []struct { + name string + final string + kill bool + exitError bool + wantError bool + }{ + {name: "success", final: `{"claims":[]}`}, + {name: "invalid output", final: `{"wrong":true}`, wantError: true}, + {name: "killed after grace", final: `{"claims":[]}`, kill: true}, + {name: "nonzero exit", final: `{"claims":[]}`, exitError: true, wantError: true}, + } { + t.Run(tc.name, func(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + if runtime.GOOS == "windows" { + t.Skip("codex auth home permission gates require Unix permission bits") + } + authHome := t.TempDir() + requireChecks.NoError(os.Chmod(authHome, 0o700)) + initial := syntheticCodexAuth("user-one", "workspace-one", "old-refresh") + refreshed := syntheticCodexAuth("user-one", "workspace-one", "new-refresh") + requireChecks.NoError(os.WriteFile(filepath.Join(authHome, "auth.json"), initial, 0o600)) + var workRoot string + base := successfulCodexScript(t, &codexTranscript{}, "gpt-test", []string{"high"}, nil, tc.final) + killed := make(chan struct{}) + starter := &recordingCodexStarter{t: t, inspect: func(dir string) { workRoot = dir }, scripts: []func(*bufio.Reader, io.Writer, io.Writer) error{ + func(reader *bufio.Reader, stdout, stderr io.Writer) error { + if err := os.WriteFile(filepath.Join(workRoot, ".codex", "auth.json"), refreshed, 0o600); err != nil { + return err + } + if err := base(reader, stdout, stderr); err != nil { + return err + } + if tc.kill { + <-killed + } + if tc.kill || tc.exitError { + return errors.New("synthetic process failure") + } + return nil + }, + }} + starter.configureProcess = func(process *pipeRPCProcess) { process.onKill = func() { close(killed) } } + driver, err := peoplesweep.NewCodexAppServerDriverWithAuthHome(codexTestConfig(), starter, &recordingCodexGate{}, authHome) + requireChecks.NoError(err) + profile := codexTestProfile(t) + prepared, err := driver.Prepare(profile, codexTestRequest()) + requireChecks.NoError(err) + response, err := driver.GeneratePrepared(t.Context(), profile, peoplesweep.Credential{}, prepared) + if tc.wantError { + requireChecks.Error(err) + assertChecks.Empty(response.CandidateJSON) + } else { + requireChecks.NoError(err) + assertChecks.JSONEq(`{"claims":[]}`, string(response.CandidateJSON)) + } + contents, err := os.ReadFile(filepath.Join(authHome, "auth.json")) + requireChecks.NoError(err) + assertChecks.Equal(sha256.Sum256(refreshed), sha256.Sum256(contents)) + info, err := os.Lstat(filepath.Join(authHome, "auth.json")) + requireChecks.NoError(err) + assertChecks.Equal(os.FileMode(0o600), info.Mode().Perm()) + assertChecks.NoDirExists(workRoot) + }) + } +} + +func TestCodexModelListingCopiesBackRefresh(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + if runtime.GOOS == "windows" { + t.Skip("codex auth home permission gates require Unix permission bits") + } + authHome := t.TempDir() + require.NoError(os.Chmod(authHome, 0o700)) + require.NoError(os.WriteFile(filepath.Join(authHome, "auth.json"), syntheticCodexAuth("user-one", "workspace-one", "old-refresh"), 0o600)) + refreshed := syntheticCodexAuth("user-one", "workspace-one", "new-refresh") + var workRoot string + starter := &recordingCodexStarter{t: t, inspect: func(dir string) { workRoot = dir }, scripts: []func(*bufio.Reader, io.Writer, io.Writer) error{ + func(reader *bufio.Reader, stdout, _ io.Writer) error { + for _, method := range []string{"initialize", "initialized", "model/list"} { + line, err := reader.ReadBytes('\n') + if err != nil { + return fmt.Errorf("read model-list request: %w", err) + } + var request struct { + ID int64 `json:"id"` + Method string `json:"method"` + } + if err := json.Unmarshal(line, &request); err != nil { + return err + } + if request.Method != method { + return fmt.Errorf("expected %s, got %s", method, request.Method) + } + if method == "initialized" { + continue + } + result := map[string]any{} + if method == "model/list" { + if err := os.WriteFile(filepath.Join(workRoot, ".codex", "auth.json"), refreshed, 0o600); err != nil { + return err + } + result["data"] = []any{} + } + if err := writeRPCFrame(stdout, map[string]any{"id": request.ID, "result": result}); err != nil { + return err + } + } + return nil + }, + }} + driver, err := peoplesweep.NewCodexAppServerDriverWithAuthHome(codexTestConfig(), starter, &recordingCodexGate{}, authHome) + require.NoError(err) + _, err = driver.ListModels(t.Context()) + require.NoError(err) + contents, err := os.ReadFile(filepath.Join(authHome, "auth.json")) + require.NoError(err) + assert.Equal(refreshed, contents) +} + +func TestCodexInferenceRejectsChangedAccountDuringRefresh(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + if runtime.GOOS == "windows" { + t.Skip("codex auth home permission gates require Unix permission bits") + } + authHome := t.TempDir() + requireChecks.NoError(os.Chmod(authHome, 0o700)) + initial := syntheticCodexAuth("user-one", "workspace-one", "old-refresh") + changed := syntheticCodexAuth("user-two", "workspace-one", "new-refresh") + requireChecks.NoError(os.WriteFile(filepath.Join(authHome, "auth.json"), initial, 0o600)) + var workRoot string + base := successfulCodexScript(t, &codexTranscript{}, "gpt-test", []string{"high"}, nil, `{"claims":[]}`) + starter := &recordingCodexStarter{t: t, inspect: func(dir string) { workRoot = dir }, scripts: []func(*bufio.Reader, io.Writer, io.Writer) error{ + func(reader *bufio.Reader, stdout, stderr io.Writer) error { + if err := base(reader, stdout, stderr); err != nil { + return err + } + return os.WriteFile(filepath.Join(workRoot, ".codex", "auth.json"), changed, 0o600) + }, + }} + driver, err := peoplesweep.NewCodexAppServerDriverWithAuthHome(codexTestConfig(), starter, &recordingCodexGate{}, authHome) + requireChecks.NoError(err) + profile := codexTestProfile(t) + prepared, err := driver.Prepare(profile, codexTestRequest()) + requireChecks.NoError(err) + response, err := driver.GeneratePrepared(t.Context(), profile, peoplesweep.Credential{}, prepared) + requireChecks.ErrorContains(err, "account identity changed") + assertChecks.Empty(response.CandidateJSON) + contents, err := os.ReadFile(filepath.Join(authHome, "auth.json")) + requireChecks.NoError(err) + assertChecks.Equal(sha256.Sum256(initial), sha256.Sum256(contents)) + assertChecks.NoDirExists(workRoot) +} + func decodeLengthPrefixedComponents(t *testing.T, wire []byte) [][]byte { t.Helper() var components [][]byte @@ -358,6 +583,8 @@ func decodeLengthPrefixedComponents(t *testing.T, wire []byte) [][]byte { } func TestCodexTransportUsesEphemeralSchemaConstrainedTurn(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) checks := assert.New(t) must := require.New(t) transport, starter, gate, transcript := newSuccessfulCodexTransport(t, `{"claims":[]}`) @@ -368,40 +595,34 @@ func TestCodexTransportUsesEphemeralSchemaConstrainedTurn(t *testing.T) { response, err := transport.GeneratePrepared(t.Context(), profile, peoplesweep.Credential{}, prepared) must.NoError(err) checks.JSONEq(`{"claims":[]}`, string(response.CandidateJSON)) - checks.Equal([]string{"initialize", "model/list", "thread/start", "turn/start"}, transcript.methods) - must.Len(transcript.frames, 4) + assertChecks.Equal([]string{"initialize", "initialized", "model/list", "thread/start", "turn/start"}, transcript.methods) + must.Len(transcript.frames, 5) var threadStart struct { Params struct { - Model string `json:"model"` - Effort string `json:"effort"` - Ephemeral bool `json:"ephemeral"` - CWD string `json:"cwd"` - RuntimeWorkspaceRoots []string `json:"runtimeWorkspaceRoots"` - SelectedCapabilityRoots []string `json:"selectedCapabilityRoots"` - DynamicTools []any `json:"dynamicTools"` - Environments []any `json:"environments"` - ApprovalPolicy string `json:"approvalPolicy"` - Sandbox string `json:"sandbox"` - SandboxPolicy struct { - Type string `json:"type"` - NetworkAccess bool `json:"networkAccess"` - } `json:"sandboxPolicy"` + Model string `json:"model"` + Ephemeral bool `json:"ephemeral"` + CWD string `json:"cwd"` + ApprovalPolicy string `json:"approvalPolicy"` + Sandbox string `json:"sandbox"` } `json:"params"` } - must.NoError(json.Unmarshal(transcript.frames[2], &threadStart)) + must.NoError(json.Unmarshal(transcript.frames[3], &threadStart)) + var threadFields struct { + Params map[string]json.RawMessage `json:"params"` + } + requireChecks.NoError(json.Unmarshal(transcript.frames[3], &threadFields)) + assertChecks.NotContains(threadFields.Params, "effort") + assertChecks.NotContains(threadFields.Params, "sandboxPolicy") checks.Equal("gpt-test", threadStart.Params.Model) - checks.Equal("high", threadStart.Params.Effort) checks.True(threadStart.Params.Ephemeral) - checks.Equal(".", threadStart.Params.CWD) - checks.Equal([]string{"."}, threadStart.Params.RuntimeWorkspaceRoots) - checks.Empty(threadStart.Params.SelectedCapabilityRoots) - checks.Empty(threadStart.Params.DynamicTools) - checks.Empty(threadStart.Params.Environments) + checks.Equal("/work", threadStart.Params.CWD) + assertChecks.NotContains(threadFields.Params, "runtimeWorkspaceRoots") + assertChecks.NotContains(threadFields.Params, "selectedCapabilityRoots") + assertChecks.NotContains(threadFields.Params, "dynamicTools") + assertChecks.NotContains(threadFields.Params, "environments") checks.Equal("never", threadStart.Params.ApprovalPolicy) checks.Equal("read-only", threadStart.Params.Sandbox) - checks.Equal("readOnly", threadStart.Params.SandboxPolicy.Type) - checks.False(threadStart.Params.SandboxPolicy.NetworkAccess) var turnStart struct { Params struct { @@ -410,18 +631,30 @@ func TestCodexTransportUsesEphemeralSchemaConstrainedTurn(t *testing.T) { Type string `json:"type"` Text string `json:"text"` } `json:"input"` - Model string `json:"model"` - Effort string `json:"effort"` - OutputSchema json.RawMessage `json:"outputSchema"` + Model string `json:"model"` + Effort string `json:"effort"` + OutputSchema json.RawMessage `json:"outputSchema"` + SandboxPolicy struct { + Type string `json:"type"` + NetworkAccess bool `json:"networkAccess"` + } `json:"sandboxPolicy"` } `json:"params"` } - must.NoError(json.Unmarshal(transcript.frames[3], &turnStart)) + must.NoError(json.Unmarshal(transcript.frames[4], &turnStart)) + var turnFields struct { + Params map[string]json.RawMessage `json:"params"` + } + requireChecks.NoError(json.Unmarshal(transcript.frames[4], &turnFields)) + assertChecks.NotContains(turnFields.Params, "sandbox") checks.Equal("thr_test", turnStart.Params.ThreadID) checks.Equal("gpt-test", turnStart.Params.Model) checks.Equal("high", turnStart.Params.Effort) + assertChecks.Equal("readOnly", turnStart.Params.SandboxPolicy.Type) + assertChecks.False(turnStart.Params.SandboxPolicy.NetworkAccess) must.Len(turnStart.Params.Input, 1) checks.Equal("text", turnStart.Params.Input[0].Type) - checks.Equal("Read packet.json and return only JSON matching the supplied output schema.", turnStart.Params.Input[0].Text) + assertChecks.NotContains(turnStart.Params.Input[0].Text, "packet.json") + assertChecks.Contains(turnStart.Params.Input[0].Text, request.InputText) checks.JSONEq(string(request.JSONSchema), string(turnStart.Params.OutputSchema)) must.Len(starter.records, 1) @@ -432,7 +665,36 @@ func TestCodexTransportUsesEphemeralSchemaConstrainedTurn(t *testing.T) { checks.NoDirExists(record.dir, "packet root must be removed after process join") } +func TestCodexTurnPreservesPacketContainingReservedThreadText(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + transport, _, _, transcript := newSuccessfulCodexTransport(t, `{"claims":[]}`) + profile := codexTestProfile(t) + request := codexTestRequest() + marker := strings.Repeat("t", 128) + request.InputText += marker + prepared, err := transport.Prepare(profile, request) + requireChecks.NoError(err) + response, err := transport.GeneratePrepared(t.Context(), profile, peoplesweep.Credential{}, prepared) + requireChecks.NoError(err) + assertChecks.JSONEq(`{"claims":[]}`, string(response.CandidateJSON)) + requireChecks.Len(transcript.frames, 5) + var turn struct { + Params struct { + ThreadID string `json:"threadId"` + Input []struct { + Text string `json:"text"` + } `json:"input"` + } `json:"params"` + } + requireChecks.NoError(json.Unmarshal(transcript.frames[4], &turn)) + assertChecks.Equal("thr_test", turn.Params.ThreadID) + requireChecks.Len(turn.Params.Input, 1) + assertChecks.Equal("Return only JSON matching the supplied output schema.\n\n"+request.InputText, turn.Params.Input[0].Text) +} + func TestCodexPreparedWireCoversPacketAndEveryOutboundFrame(t *testing.T) { + assertChecks := assert.New(t) checks := assert.New(t) must := require.New(t) transport, starter, _, transcript := newSuccessfulCodexTransport(t, `{"claims":[]}`) @@ -441,7 +703,7 @@ func TestCodexPreparedWireCoversPacketAndEveryOutboundFrame(t *testing.T) { prepared, err := transport.Prepare(profile, request) must.NoError(err) components := decodeLengthPrefixedComponents(t, prepared.WireRequest()) - must.Len(components, 5) + require.Len(t, components, 6) checks.Equal([]byte(request.InputText), components[0]) for index := 1; index < len(components); index++ { checks.True(bytes.HasSuffix(components[index], []byte("\n")), "JSONL frame %d", index) @@ -449,11 +711,11 @@ func TestCodexPreparedWireCoversPacketAndEveryOutboundFrame(t *testing.T) { _, err = transport.GeneratePrepared(t.Context(), profile, peoplesweep.Credential{}, prepared) must.NoError(err) - checks.Equal(components[1:4], transcript.frames[:3]) + assertChecks.Equal(components[1:5], transcript.frames[:4]) var reservedTurn map[string]any var launchedTurn map[string]any - must.NoError(json.Unmarshal(components[4], &reservedTurn)) - must.NoError(json.Unmarshal(transcript.frames[3], &launchedTurn)) + must.NoError(json.Unmarshal(components[5], &reservedTurn)) + must.NoError(json.Unmarshal(transcript.frames[4], &launchedTurn)) reservedParams, ok := reservedTurn["params"].(map[string]any) must.True(ok) launchedParams, ok := launchedTurn["params"].(map[string]any) @@ -462,24 +724,31 @@ func TestCodexPreparedWireCoversPacketAndEveryOutboundFrame(t *testing.T) { must.True(ok) checks.Len(reservedID, 128) checks.Equal("thr_test", launchedParams["threadId"]) - wantLaunchedTurn := bytes.Replace(components[4], []byte(reservedID), []byte("thr_test"), 1) - checks.Equal(wantLaunchedTurn, transcript.frames[3], + wantLaunchedTurn := bytes.Replace(components[5], []byte(reservedID), []byte("thr_test"), 1) + checks.Equal(wantLaunchedTurn, transcript.frames[4], "the server thread-ID slot must be the only changed wire bytes") reservedParams["threadId"] = launchedParams["threadId"] checks.Equal(reservedTurn, launchedTurn, "only the bounded server thread-ID slot may change") - actualWireBytes := len(prepared.WireRequest()) - len(components[4]) + len(transcript.frames[3]) + actualWireBytes := len(prepared.WireRequest()) - len(components[5]) + len(transcript.frames[4]) checks.GreaterOrEqual(len(prepared.WireRequest()), actualWireBytes, "reservation must cover the response-dependent turn frame") must.Len(starter.records, 1) record := starter.records[0] - checks.Equal(components[0], transcript.packet) - must.Error(transcript.packetWrite, "packet must reject writes") - checks.Equal([]string{"packet.json"}, transcript.rootEntries) + assertChecks.Contains(string(transcript.frames[4]), "private packet marker") + assertChecks.Empty(transcript.rootEntries) checks.NoDirExists(record.dir) wireCopy := prepared.WireRequest() wireCopy[len(wireCopy)-1] ^= 0xff checks.NotEqual(wireCopy, prepared.WireRequest(), "wire accessor must return a copy") + + changed := request + changed.InputText = strings.Replace(request.InputText, "private", "Private", 1) + changedPrepared, err := transport.Prepare(profile, changed) + must.NoError(err) + assertChecks.NotEqual(prepared.WireSHA256(), changedPrepared.WireSHA256()) + changedComponents := decodeLengthPrefixedComponents(t, changedPrepared.WireRequest()) + assertChecks.NotEqual(components[5], changedComponents[5], "the disclosed turn must change with the packet") } func TestCodexTransportRejectsUnsupportedModelAndEffort(t *testing.T) { @@ -802,7 +1071,6 @@ func TestCodexCleanupClosesStreamsOnNonzeroExit(t *testing.T) { } func TestCodexTransportRejectsUnboundedModelCatalog(t *testing.T) { - checks := assert.New(t) must := require.New(t) cursor := "more-models" transcript := &codexTranscript{} @@ -816,7 +1084,7 @@ func TestCodexTransportRejectsUnboundedModelCatalog(t *testing.T) { must.NoError(err) _, err = transport.GeneratePrepared(t.Context(), profile, peoplesweep.Credential{}, prepared) must.ErrorIs(err, peoplesweep.ErrInvalidStructuredOutput) - checks.Equal([]string{"initialize", "model/list"}, transcript.methods) + assert.Equal(t, []string{"initialize", "initialized", "model/list"}, transcript.methods) } func TestCodexTransportRejectsMalformedOrOversizedThreadIDBeforeTurn(t *testing.T) { @@ -851,6 +1119,13 @@ func TestCodexTransportRejectsMalformedOrOversizedThreadIDBeforeTurn(t *testing. if err := writeRPCFrame(stdout, map[string]any{"id": id + 1, "result": result}); err != nil { return err } + if id == 0 { + line, err := reader.ReadBytes('\n') + if err != nil { + return fmt.Errorf("read codex initialized notification: %w", err) + } + transcript.record(line) + } } _, err := reader.ReadBytes('\n') if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) { @@ -870,7 +1145,7 @@ func TestCodexTransportRejectsMalformedOrOversizedThreadIDBeforeTurn(t *testing. _, err = transport.GeneratePrepared(t.Context(), profile, peoplesweep.Credential{}, prepared) must.ErrorIs(err, peoplesweep.ErrInvalidStructuredOutput) checks.NotContains(err.Error(), test.threadID) - checks.Equal([]string{"initialize", "model/list", "thread/start"}, transcript.methods) + assert.Equal(t, []string{"initialize", "initialized", "model/list", "thread/start"}, transcript.methods) }) } } @@ -907,7 +1182,7 @@ func TestCodexTransportRejectsInvalidFinalSchema(t *testing.T) { checks.Equal("gpt-test", response.ModelVersion) checks.Equal(int64(1), starter.starts.Load()) checks.Equal(int64(1), gate.verifyCalls.Load()) - checks.Equal([]string{"initialize", "model/list", "thread/start", "turn/start"}, transcript.methods) + assert.Equal(t, []string{"initialize", "initialized", "model/list", "thread/start", "turn/start"}, transcript.methods) } func TestCodexTransportPreservesUsageWhenCumulativeTotalsAreInvalid(t *testing.T) { @@ -923,12 +1198,12 @@ func TestCodexTransportPreservesUsageWhenCumulativeTotalsAreInvalid(t *testing.T {name: "missing output", writeBad: func(w io.Writer) error { return writeRPCFrame(w, map[string]any{"method": "thread/tokenUsage/updated", "params": map[string]any{ "threadId": "thr_test", "turnId": "turn_test", - "tokenUsage": map[string]any{"totalTokenUsage": map[string]any{"inputTokens": 22}}, + "tokenUsage": map[string]any{"total": map[string]any{"inputTokens": 22}}, }}) }}, {name: "decreasing", writeBad: func(w io.Writer) error { return writeCodexUsageEvent(w, 20, 3) }}, {name: "overflow", writeBad: func(w io.Writer) error { - _, err := io.WriteString(w, `{"method":"thread/tokenUsage/updated","params":{"threadId":"thr_test","turnId":"turn_test","tokenUsage":{"totalTokenUsage":{"inputTokens":9223372036854775808,"outputTokens":5}}}}`+"\n") + _, err := io.WriteString(w, `{"method":"thread/tokenUsage/updated","params":{"threadId":"thr_test","turnId":"turn_test","tokenUsage":{"total":{"inputTokens":9223372036854775808,"outputTokens":5}}}}`+"\n") return err }}, } @@ -993,12 +1268,12 @@ func TestCodexTransportConsumesNotificationsQueuedBeforeTurnResponseOnce(t *test must.NoError(err) checks.JSONEq(`{"claims":[]}`, string(response.CandidateJSON)) checks.Equal(peoplesweep.TokenUsage{InputTokens: 22, OutputTokens: 5}, response.Usage) - checks.Equal([]string{"initialize", "model/list", "thread/start", "turn/start"}, transcript.methods) + assert.Equal(t, []string{"initialize", "initialized", "model/list", "thread/start", "turn/start"}, transcript.methods) } func TestCodexDriverMarksReportedZeroUsageKnown(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) transcript := &codexTranscript{} starter := &recordingCodexStarter{t: t, scripts: []func(*bufio.Reader, io.Writer, io.Writer) error{ codexTurnEventScript(t, transcript, nil, func(stdout io.Writer) error { @@ -1013,35 +1288,35 @@ func TestCodexDriverMarksReportedZeroUsageKnown(t *testing.T) { }} driver, err := peoplesweep.NewCodexAppServerDriver( codexTestConfig(), starter, &recordingCodexGate{}) - require.NoError(err) + requireChecks.NoError(err) profile := codexTestProfile(t) prepared, err := driver.Prepare(profile, codexTestRequest()) - require.NoError(err) + requireChecks.NoError(err) response, err := driver.GeneratePrepared( t.Context(), profile, peoplesweep.Credential{}, prepared) - require.NoError(err) - assert.True(response.UsageKnown) - assert.Equal(peoplesweep.TokenUsage{}, response.Usage) + requireChecks.NoError(err) + assertChecks.True(response.UsageKnown) + assertChecks.Equal(peoplesweep.TokenUsage{}, response.Usage) } func TestCodexDriverRejectsNonEmptyCredentialBeforeAttestation(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) gate := &recordingCodexGate{} starter := &recordingCodexStarter{t: t} driver, err := peoplesweep.NewCodexAppServerDriver(codexTestConfig(), starter, gate) - require.NoError(err) + requireChecks.NoError(err) profile := codexTestProfile(t) prepared, err := driver.Prepare(profile, codexTestRequest()) - require.NoError(err) + requireChecks.NoError(err) _, err = driver.GeneratePrepared(t.Context(), profile, peoplesweep.NewCredential(peoplesweep.AuthBearer, "codex-secret-canary"), prepared) - require.ErrorContains(err, "does not accept") - assert.NotContains(err.Error(), "codex-secret-canary") - assert.Zero(gate.verifyCalls.Load()) - assert.Zero(starter.starts.Load()) + requireChecks.ErrorContains(err, "does not accept") + assertChecks.NotContains(err.Error(), "codex-secret-canary") + assertChecks.Zero(gate.verifyCalls.Load()) + assertChecks.Zero(starter.starts.Load()) } func TestCodexTransportRejectsLateStderrOverflowAfterFinalFrame(t *testing.T) { @@ -1118,7 +1393,7 @@ func TestCodexDeviceLoginUsesDeviceCodeMethod(t *testing.T) { var frames [][]byte starter := &recordingCodexStarter{t: t, scripts: []func(*bufio.Reader, io.Writer, io.Writer) error{ func(reader *bufio.Reader, stdout, _ io.Writer) error { - for id, wantMethod := range []string{"initialize", "account/login/start"} { + for id, wantMethod := range []string{"initialize", "initialized", "account/login/start"} { line, err := reader.ReadBytes('\n') if err != nil { return fmt.Errorf("read codex login frame: %w", err) @@ -1130,6 +1405,15 @@ func TestCodexDeviceLoginUsesDeviceCodeMethod(t *testing.T) { } continue } + if id == 1 { + var got struct { + Method string `json:"method"` + } + if err := json.Unmarshal(line, &got); err != nil || got.Method != "initialized" { + return errors.New("missing codex initialized notification") + } + continue + } var got struct { Method string `json:"method"` Params map[string]any `json:"params"` @@ -1146,7 +1430,6 @@ func TestCodexDeviceLoginUsesDeviceCodeMethod(t *testing.T) { if err := writeRPCFrame(stdout, map[string]any{"id": 2, "result": map[string]any{ "type": "chatgptDeviceCode", "loginId": "login-safe", "verificationUrl": "https://auth.example.test/device", "userCode": "ABCD-1234", - "expiresAt": "2026-08-23T12:30:00Z", }}); err != nil { return err } @@ -1160,6 +1443,7 @@ func TestCodexDeviceLoginUsesDeviceCodeMethod(t *testing.T) { transport, err := peoplesweep.NewCodexAppServerDriver(codexTestConfig(), starter, &recordingCodexGate{}) must.NoError(err) var login peoplesweep.DeviceLogin + started := time.Now() err = transport.StartDeviceLogin(t.Context(), func(value peoplesweep.DeviceLogin) error { login = value return nil @@ -1167,8 +1451,8 @@ func TestCodexDeviceLoginUsesDeviceCodeMethod(t *testing.T) { must.NoError(err) checks.Equal("https://auth.example.test/device", login.VerificationURL) checks.Equal("ABCD-1234", login.UserCode) - checks.Equal(time.Date(2026, 8, 23, 12, 30, 0, 0, time.UTC), login.ExpiresAt) - must.Len(frames, 2) + assert.WithinRange(t, login.ExpiresAt, started, started.Add(2*time.Second)) + require.Len(t, frames, 3) } func TestCodexModelListReturnsSupportedEfforts(t *testing.T) { @@ -1186,6 +1470,18 @@ func TestCodexModelListReturnsSupportedEfforts(t *testing.T) { return err } line, err = reader.ReadBytes('\n') + if err != nil { + return fmt.Errorf("read codex initialized notification: %w", err) + } + transcript.record(line) + var notification map[string]any + if err := json.Unmarshal(line, ¬ification); err != nil { + return err + } + if notification["method"] != "initialized" || len(notification) != 1 { + return errors.New("initialize was not followed by the initialized notification") + } + line, err = reader.ReadBytes('\n') if err != nil { return fmt.Errorf("read codex model-list frame: %w", err) } @@ -1210,7 +1506,7 @@ func TestCodexModelListReturnsSupportedEfforts(t *testing.T) { ID: "gpt-test", DisplayName: "Test Model", DefaultReasoningEffort: "medium", SupportedEfforts: []string{"low", "medium"}, }}, models) - checks.Equal([]string{"initialize", "model/list"}, transcript.methods) + assert.Equal(t, []string{"initialize", "initialized", "model/list"}, transcript.methods) } func TestCodexEveryProcessRequiresIsolationGate(t *testing.T) { @@ -1308,33 +1604,37 @@ func TestCodexTransportRejectsModelVersionChangeAcrossBatches(t *testing.T) { } func TestCodexLaunchScrubsEnvironmentAndDisablesExtensions(t *testing.T) { - checks := assert.New(t) - must := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) t.Setenv("PACKET_SECRET", "must-not-forward") t.Setenv("OPENAI_API_KEY", "must-not-forward") t.Setenv("CODEX_HOME", filepath.Join(t.TempDir(), "auth-store")) transport, starter, _, transcript := newSuccessfulCodexTransport(t, `{"claims":[]}`) profile := codexTestProfile(t) prepared, err := transport.Prepare(profile, codexTestRequest()) - must.NoError(err) + requireChecks.NoError(err) _, err = transport.GeneratePrepared(t.Context(), profile, peoplesweep.Credential{}, prepared) - must.NoError(err) - must.Len(starter.records, 1) + requireChecks.NoError(err) + requireChecks.Len(starter.records, 1) record := starter.records[0] joinedEnv := strings.Join(record.env, "\n") - checks.Contains(joinedEnv, "CODEX_HOME=") - checks.NotContains(joinedEnv, "PACKET_SECRET") - checks.NotContains(joinedEnv, "OPENAI_API_KEY") - checks.Equal([]string{ + assertChecks.NotContains(joinedEnv, "CODEX_HOME=") + assertChecks.NotContains(joinedEnv, "PACKET_SECRET") + assertChecks.NotContains(joinedEnv, "OPENAI_API_KEY") + assertChecks.Equal([]string{ "app-server", "--stdio", "--strict-config", "--disable", "plugins", "--disable", "apps", "--disable", "enable_mcp_apps", "--disable", "browser_use", "--disable", "computer_use", "--disable", "image_generation", "--disable", "skill_search", "--disable", "hooks", "--disable", "memories", "--disable", "multi_agent", "-c", "mcp_servers={}", "-c", "analytics.enabled=false", }, record.args) - for _, frame := range transcript.frames { - checks.NotContains(string(frame), codexTestRequest().InputText) - checks.NotContains(string(frame), "projectId") - checks.NotContains(string(frame), "developerInstructions") + for index, frame := range transcript.frames { + if index == len(transcript.frames)-1 { + assertChecks.Contains(string(frame), "private packet marker") + } else { + assertChecks.NotContains(string(frame), "private packet marker") + } + assertChecks.NotContains(string(frame), "projectId") + assertChecks.NotContains(string(frame), "developerInstructions") } } diff --git a/internal/peoplesweep/codex_artifact_linux.go b/internal/peoplesweep/codex_artifact_linux.go index 7910ffaab..af0b7ff9b 100644 --- a/internal/peoplesweep/codex_artifact_linux.go +++ b/internal/peoplesweep/codex_artifact_linux.go @@ -17,7 +17,8 @@ func validateCodexLaunchArtifact(path string, artifact CodexLaunchArtifact) erro return errors.New("read native Codex launch artifact") } defer func() { _ = file.Close() }() - if file.Type != elf.ET_EXEC || file.Machine != nativeCodexELFMachine() { + if (file.Type != elf.ET_EXEC && file.Type != elf.ET_DYN) || + file.Machine != nativeCodexELFMachine() || file.Entry == 0 { return errors.New("codex launch artifact is not a native executable") } for _, program := range file.Progs { diff --git a/internal/peoplesweep/codex_containment_probe_linux_test.go b/internal/peoplesweep/codex_containment_probe_linux_test.go new file mode 100644 index 000000000..437c80a9b --- /dev/null +++ b/internal/peoplesweep/codex_containment_probe_linux_test.go @@ -0,0 +1,474 @@ +//go:build linux + +package peoplesweep + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json/jsontext" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type codexContainmentProbeResult struct { + ExitCode int `json:"exitCode"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` +} + +// TestCodexPinnedArtifactNegativeContainmentProbes sends command/exec to the +// real pinned app-server through the production launcher. The command executes +// a static syscall probe from disposable /work; no host archive or credentials +// are supplied. The positive controls prove command execution and file I/O +// actually occurred before the negative checks. +func TestCodexPinnedArtifactNegativeContainmentProbes(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + artifact := os.Getenv("MSGVAULT_CODEX_PINNED_EXECUTABLE") + if artifact == "" { + t.Skip("set MSGVAULT_CODEX_PINNED_EXECUTABLE to the pinned native artifact") + } + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute) + defer cancel() + outsideRoot := t.TempDir() + hostSentinel := filepath.Join(outsideRoot, "sentinel.txt") + hostWriteTarget := filepath.Join(outsideRoot, "write-target.txt") + requireChecks.NoError(os.WriteFile(hostSentinel, []byte("SYNTHETIC_OUTSIDE_SENTINEL"), 0o600)) + listener, err := net.Listen("tcp", "127.0.0.1:0") + requireChecks.NoError(err) + defer func() { require.NoError(t, listener.Close()) }() + + digest, err := hashCodexExecutable(artifact) + requireChecks.NoError(err) + registry := map[CodexReleaseKey]CodexAttestation{ + {ExecutableSHA256: digest, ExecutionBoundary: CodexExecutionBoundaryV1}: { + Version: "codex-cli 0.156.0", ExecutableSHA256: digest, + ExecutionBoundary: CodexExecutionBoundaryV1, LaunchArtifact: CodexLaunchArtifactNativeStandaloneV1, + }, + } + launcher := codexBoundLauncher{ + gate: injectedReleasedCodexGate{registry: registry}, starter: NewCodexCommandStarter(), + } + attestation, err := launcher.Verify(ctx, artifact) + requireChecks.NoError(err) + defer func() { require.NoError(t, attestation.Close()) }() + process, err := launcher.Start(ctx, attestation, "") + requireChecks.NoError(err) + client := &CodexRPCClient{Process: process} + defer func() { require.NoError(t, finishCodexProcess(ctx, process, client, true)) }() + owned, ok := process.(*codexOwnedProcess) + requireChecks.True(ok) + workRoot := owned.workRoot + buildCodexContainmentProbe(ctx, t, workRoot) + allowedInput := filepath.Join(workRoot, "allowed-input.txt") + requireChecks.NoError(os.WriteFile(allowedInput, []byte("SYNTHETIC_ALLOWED_INPUT"), 0o600)) + var initialized map[string]any + requireChecks.NoError(client.Call(ctx, "initialize", codexInitializeRequest().Params, &initialized)) + requireChecks.NoError(client.Notify(ctx, "initialized", nil)) + + controlRead := codexRunContainmentProbe(ctx, t, client, "read", "/work/allowed-input.txt") + assertChecks.Equal("ALLOWED SYNTHETIC_ALLOWED_INPUT", strings.TrimSpace(controlRead.Stdout)) + controlWrite := codexRunContainmentProbe(ctx, t, client, "write", "/work/allowed-output.txt") + assertChecks.Equal("ALLOWED", strings.TrimSpace(controlWrite.Stdout)) + written, err := os.ReadFile(filepath.Join(workRoot, "allowed-output.txt")) + requireChecks.NoError(err) + assertChecks.Equal("SYNTHETIC_PROBE_WRITTEN", string(written)) + + read := codexRunContainmentProbe(ctx, t, client, "read", hostSentinel) + assertChecks.Equal("DENIED errno=2", strings.TrimSpace(read.Stdout)) + write := codexRunContainmentProbe(ctx, t, client, "write", hostWriteTarget) + assertChecks.Equal("DENIED errno=2", strings.TrimSpace(write.Stdout)) + assertChecks.NoFileExists(hostWriteTarget) + unchanged, err := os.ReadFile(hostSentinel) + requireChecks.NoError(err) + assertChecks.Equal("SYNTHETIC_OUTSIDE_SENTINEL", string(unchanged)) + + egress := codexRunContainmentProbe(ctx, t, client, "egress", listener.Addr().String()) + assertChecks.Regexp(`^DENIED errno=[0-9]+$`, strings.TrimSpace(egress.Stdout)) + assertChecks.NotEqual("DENIED errno=0", strings.TrimSpace(egress.Stdout)) + tcpListener, ok := listener.(*net.TCPListener) + requireChecks.True(ok) + requireChecks.NoError(tcpListener.SetDeadline(time.Now().Add(250 * time.Millisecond))) + connection, acceptErr := tcpListener.Accept() + if connection != nil { + _ = connection.Close() + } + var networkError net.Error + requireChecks.ErrorAs(acceptErr, &networkError, "host loopback listener must receive no connection") + assertChecks.True(networkError.Timeout()) + t.Logf("read=%q write=%q egress=%q; control-read=%q control-write=%q", + strings.TrimSpace(read.Stdout), strings.TrimSpace(write.Stdout), strings.TrimSpace(egress.Stdout), + strings.TrimSpace(controlRead.Stdout), strings.TrimSpace(controlWrite.Stdout)) +} + +func TestCodexPinnedArtifactProxyBridge(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + artifact := os.Getenv("MSGVAULT_CODEX_PINNED_EXECUTABLE") + if artifact == "" { + t.Skip("set MSGVAULT_CODEX_PINNED_EXECUTABLE to the pinned native artifact") + } + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute) + defer cancel() + outsideRoot := t.TempDir() + hostSentinel := filepath.Join(outsideRoot, "sentinel.txt") + hostWriteTarget := filepath.Join(outsideRoot, "write-target.txt") + requireChecks.NoError(os.WriteFile(hostSentinel, []byte("SYNTHETIC_OUTSIDE_SENTINEL"), 0o600)) + var starter CommandStarter + if os.Getenv("MSGVAULT_CODEX_TEST_DEFAULT_BRIDGE") == "1" { + starter = NewCodexCommandStarter() + } else { + bridge := buildCodexProxyBridge(ctx, t) + bridgeDigest, err := hashCodexExecutable(bridge) + requireChecks.NoError(err) + starter = newCodexCommandStarterWithBridge(bridge, bridgeDigest) + } + upstream, err := net.Listen("tcp", "127.0.0.1:0") + requireChecks.NoError(err) + defer func() { require.NoError(t, upstream.Close()) }() + var dials []string + var dialMu sync.Mutex + proxy := newCodexHostServiceProxy(func(ctx context.Context, network, address string) (net.Conn, error) { + dialMu.Lock() + dials = append(dials, address) + dialMu.Unlock() + if address == "auth.openai.com:443" { + client, server := net.Pipe() + _ = server.Close() + return client, nil + } + return (&net.Dialer{}).DialContext(ctx, network, upstream.Addr().String()) + }) + digest, err := hashCodexExecutable(artifact) + requireChecks.NoError(err) + registry := map[CodexReleaseKey]CodexAttestation{ + {ExecutableSHA256: digest, ExecutionBoundary: CodexExecutionBoundaryV1}: { + Version: "codex-cli 0.156.0", ExecutableSHA256: digest, + ExecutionBoundary: CodexExecutionBoundaryV1, LaunchArtifact: CodexLaunchArtifactNativeStandaloneV1, + }, + } + launcher := codexBoundLauncher{ + gate: injectedReleasedCodexGate{registry: registry}, + starter: starter, proxy: proxy, + } + attestation, err := launcher.Verify(ctx, artifact) + requireChecks.NoError(err) + defer func() { require.NoError(t, attestation.Close()) }() + process, err := launcher.Start(ctx, attestation, "") + requireChecks.NoError(err) + client := &CodexRPCClient{Process: process} + defer func() { require.NoError(t, finishCodexProcess(ctx, process, client, true)) }() + owned, ok := process.(*codexOwnedProcess) + requireChecks.True(ok) + buildCodexContainmentProbe(ctx, t, owned.workRoot) + var initialized map[string]any + requireChecks.NoError(client.Call(ctx, "initialize", codexInitializeRequest().Params, &initialized)) + requireChecks.NoError(client.Notify(ctx, "initialized", nil)) + denied := codexRunContainmentProbe(ctx, t, client, "proxy", "example.com:443") + assertChecks.Equal("DENIED HTTP/1.1 403 Forbidden", strings.TrimSpace(denied.Stdout)) + allowed := codexRunContainmentProbe(ctx, t, client, "proxy", "chatgpt.com:443") + assertChecks.Equal("ALLOWED HTTP/1.1 200 Connection Established", strings.TrimSpace(allowed.Stdout)) + dialMu.Lock() + assertChecks.Equal([]string{"chatgpt.com:443"}, dials) + dialMu.Unlock() + direct := codexRunContainmentProbe(ctx, t, client, "egress", upstream.Addr().String()) + assertChecks.Regexp(`^DENIED errno=[0-9]+$`, strings.TrimSpace(direct.Stdout)) + read := codexRunContainmentProbe(ctx, t, client, "read", hostSentinel) + assertChecks.Equal("DENIED errno=2", strings.TrimSpace(read.Stdout)) + write := codexRunContainmentProbe(ctx, t, client, "write", hostWriteTarget) + assertChecks.Equal("DENIED errno=2", strings.TrimSpace(write.Stdout)) + assertChecks.NoFileExists(hostWriteTarget) + loginCtx, cancelLogin := context.WithTimeout(ctx, 10*time.Second) + defer cancelLogin() + var login map[string]any + _ = client.Call(loginCtx, "account/login/start", map[string]string{"type": "chatgptDeviceCode"}, &login) + dialMu.Lock() + loginReachedProxy := slices.Contains(dials, "auth.openai.com:443") + assertChecks.True(loginReachedProxy, "native device login must use the isolated bridge; approved dials: %v", dials) + dialMu.Unlock() + t.Logf("proxy denied=%q allowed=%q direct-egress=%q read=%q write=%q; native login reached approved proxy=%t", + strings.TrimSpace(denied.Stdout), strings.TrimSpace(allowed.Stdout), strings.TrimSpace(direct.Stdout), + strings.TrimSpace(read.Stdout), strings.TrimSpace(write.Stdout), loginReachedProxy) +} + +// TestCodexPinnedArtifactAuthTLSRoots performs only a TLS handshake to the +// approved auth authority through the production launcher. It sends no HTTP +// request and cannot start a device-code ceremony. +func TestCodexPinnedArtifactAuthTLSRoots(t *testing.T) { + requireChecks := require.New(t) + artifact := os.Getenv("MSGVAULT_CODEX_PINNED_EXECUTABLE") + if artifact == "" { + t.Skip("set MSGVAULT_CODEX_PINNED_EXECUTABLE to the pinned native artifact") + } + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute) + defer cancel() + digest, err := hashCodexExecutable(artifact) + requireChecks.NoError(err) + registry := map[CodexReleaseKey]CodexAttestation{ + {ExecutableSHA256: digest, ExecutionBoundary: CodexExecutionBoundaryV1}: { + Version: "codex-cli 0.156.0", ExecutableSHA256: digest, + ExecutionBoundary: CodexExecutionBoundaryV1, LaunchArtifact: CodexLaunchArtifactNativeStandaloneV1, + }, + } + launcher := codexBoundLauncher{ + gate: injectedReleasedCodexGate{registry: registry}, starter: NewCodexCommandStarter(), + proxy: defaultCodexServiceProxy(), + } + attestation, err := launcher.Verify(ctx, artifact) + requireChecks.NoError(err) + defer func() { require.NoError(t, attestation.Close()) }() + process, err := launcher.Start(ctx, attestation, "") + requireChecks.NoError(err) + client := &CodexRPCClient{Process: process} + defer func() { require.NoError(t, finishCodexProcess(ctx, process, client, true)) }() + owned, ok := process.(*codexOwnedProcess) + requireChecks.True(ok) + buildCodexContainmentProbe(ctx, t, owned.workRoot) + var initialized map[string]any + requireChecks.NoError(client.Call(ctx, "initialize", codexInitializeRequest().Params, &initialized)) + requireChecks.NoError(client.Notify(ctx, "initialized", nil)) + result := codexRunContainmentProbe(ctx, t, client, "tls", "auth.openai.com:443") + assert.Equal(t, "TLS_VERIFIED", strings.TrimSpace(result.Stdout)) +} + +func syntheticCodexProbeAuth(user, workspace, refresh string) []byte { + claims := fmt.Sprintf(`{"https://api.openai.com/auth":{"chatgpt_user_id":%q,"chatgpt_account_id":%q}}`, user, workspace) + idToken := "header." + base64.RawURLEncoding.EncodeToString([]byte(claims)) + ".signature" + return []byte(fmt.Sprintf(`{"auth_mode":"chatgpt","tokens":{"id_token":%q,"access_token":"synthetic-access","refresh_token":%q,"account_id":%q}}`, idToken, refresh, workspace)) +} + +// This diagnostic uses synthetic auth and command/exec to exercise the real +// production launcher, staged credential, and post-exit copy-back. It is not +// an authenticated structured inference or a release-gate substitute. +func TestCodexPinnedArtifactRefreshCopyBack(t *testing.T) { + artifact := os.Getenv("MSGVAULT_CODEX_PINNED_EXECUTABLE") + if artifact == "" { + t.Skip("set MSGVAULT_CODEX_PINNED_EXECUTABLE to the pinned native artifact") + } + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute) + defer cancel() + var starter CommandStarter + if os.Getenv("MSGVAULT_CODEX_TEST_DEFAULT_BRIDGE") == "1" { + starter = NewCodexCommandStarter() + } else { + bridge := buildCodexProxyBridge(ctx, t) + bridgeDigest, err := hashCodexExecutable(bridge) + require.NoError(t, err) + starter = newCodexCommandStarterWithBridge(bridge, bridgeDigest) + } + digest, err := hashCodexExecutable(artifact) + require.NoError(t, err) + registry := map[CodexReleaseKey]CodexAttestation{ + {ExecutableSHA256: digest, ExecutionBoundary: CodexExecutionBoundaryV1}: { + Version: "codex-cli 0.156.0", ExecutableSHA256: digest, + ExecutionBoundary: CodexExecutionBoundaryV1, LaunchArtifact: CodexLaunchArtifactNativeStandaloneV1, + }, + } + launcher := codexBoundLauncher{gate: injectedReleasedCodexGate{registry: registry}, starter: starter, proxy: defaultCodexServiceProxy()} + for _, tc := range []struct { + name string + user string + wantChanged bool + }{ + {name: "same account", user: "user-one"}, + {name: "changed account", user: "user-two", wantChanged: true}, + } { + t.Run(tc.name, func(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + authHome := t.TempDir() + requireChecks.NoError(os.Chmod(authHome, 0o700)) + initial := syntheticCodexProbeAuth("user-one", "workspace-one", "old-refresh") + candidate := syntheticCodexProbeAuth(tc.user, "workspace-one", "new-refresh") + requireChecks.NoError(os.WriteFile(filepath.Join(authHome, "auth.json"), initial, 0o600)) + attestation, err := launcher.Verify(ctx, artifact) + requireChecks.NoError(err) + defer func() { require.NoError(t, attestation.Close()) }() + process, err := launcher.Start(ctx, attestation, authHome) + requireChecks.NoError(err) + owned, ok := process.(*codexOwnedProcess) + requireChecks.True(ok) + client := &CodexRPCClient{Process: process} + buildCodexContainmentProbe(ctx, t, owned.workRoot) + requireChecks.NoError(os.WriteFile(filepath.Join(owned.workRoot, "refresh.json"), candidate, 0o600)) + var initialized map[string]any + requireChecks.NoError(client.Call(ctx, "initialize", codexInitializeRequest().Params, &initialized)) + requireChecks.NoError(client.Notify(ctx, "initialized", nil)) + result := codexRunContainmentProbe(ctx, t, client, "copy", "/work/refresh.json", "/work/.codex/auth.json") + assertChecks.Equal("ALLOWED", strings.TrimSpace(result.Stdout)) + err = finishCodexProcess(ctx, process, client, false) + if tc.wantChanged { + requireChecks.ErrorIs(err, ErrCodexAuthAccountChanged) + } else { + requireChecks.NoError(err) + } + contents, err := os.ReadFile(filepath.Join(authHome, "auth.json")) + requireChecks.NoError(err) + want := candidate + if tc.wantChanged { + want = initial + } + assertChecks.Equal(sha256.Sum256(want), sha256.Sum256(contents)) + info, err := os.Lstat(filepath.Join(authHome, "auth.json")) + requireChecks.NoError(err) + assertChecks.Equal(os.FileMode(0o600), info.Mode().Perm()) + assertChecks.NoDirExists(owned.workRoot) + }) + } +} + +// This opt-in ceremony uses the production model-less enrollment constructor. +// The short-lived URL and code are shown only in the operator's test output. +func TestCodexPinnedArtifactDeviceEnrollment(t *testing.T) { + requireChecks := require.New(t) + if os.Getenv("MSGVAULT_CODEX_ENROLL_NOW") != "1" { + t.Skip("device enrollment requires an explicit operator start") + } + artifact := os.Getenv("MSGVAULT_CODEX_PINNED_EXECUTABLE") + authHome := os.Getenv("MSGVAULT_CODEX_AUTH_HOME") + requireChecks.NotEmpty(artifact) + requireChecks.NotEmpty(authHome) + client, err := NewCodexEnrollmentClient(artifact, authHome, 10*time.Minute) + requireChecks.NoError(err) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Minute) + defer cancel() + err = client.StartDeviceLogin(ctx, func(login DeviceLogin) error { + t.Logf("Official verification URL: %s", login.VerificationURL) + t.Logf("One-time user code: %s", login.UserCode) + t.Logf("Local login deadline: %s", login.ExpiresAt.UTC().Format(time.RFC3339)) + return nil + }) + requireChecks.NoError(err) + contents, _, err := readPrivateCodexAuth(authHome) + requireChecks.NoError(err) + _, err = codexAccountIdentityFromAuth(contents) + requireChecks.NoError(err) +} + +// This opt-in release probe requires a daemon-selected dedicated auth home. +// It sends only a synthetic packet through the production launcher and the +// real allowlisted upstream, then validates the structured response. +func TestCodexPinnedArtifactPacketOnlyStructuredInference(t *testing.T) { + requireChecks := require.New(t) + artifact := os.Getenv("MSGVAULT_CODEX_PINNED_EXECUTABLE") + if artifact == "" { + t.Skip("set MSGVAULT_CODEX_PINNED_EXECUTABLE to the pinned native artifact") + } + authHome := os.Getenv("MSGVAULT_CODEX_AUTH_HOME") + if authHome == "" { + t.Skip("dedicated daemon-owned Codex auth home is unavailable") + } + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute) + defer cancel() + var starter CommandStarter + if os.Getenv("MSGVAULT_CODEX_TEST_DEFAULT_BRIDGE") == "1" { + starter = NewCodexCommandStarter() + } else { + bridge := buildCodexProxyBridge(ctx, t) + bridgeDigest, err := hashCodexExecutable(bridge) + requireChecks.NoError(err) + starter = newCodexCommandStarterWithBridge(bridge, bridgeDigest) + } + digest, err := hashCodexExecutable(artifact) + requireChecks.NoError(err) + registry := map[CodexReleaseKey]CodexAttestation{ + {ExecutableSHA256: digest, ExecutionBoundary: CodexExecutionBoundaryV1}: { + Version: "codex-cli 0.156.0", ExecutableSHA256: digest, + ExecutionBoundary: CodexExecutionBoundaryV1, LaunchArtifact: CodexLaunchArtifactNativeStandaloneV1, + }, + } + gate := injectedReleasedCodexGate{registry: registry} + provider := ProviderConfig{ + Protocol: ProtocolCodexAppServer, Model: "codex-model-pending-discovery", ReasoningEffort: "medium", + Auth: AuthNone, Credential: CredentialNone, OutputMode: OutputModeNativeJSONSchema, + RetentionPosture: "zero_data_retention", TrainingPosture: "no_training", + AllowedSources: []SourceClass{SourceConversationText}, SourceSince: "2026-01-01", + Executable: artifact, ExecutionBoundary: CodexExecutionBoundaryV1, RequestTimeout: 90 * time.Second, + } + driver, err := NewCodexAppServerDriverWithAuthHome(provider, starter, gate, authHome) + requireChecks.NoError(err) + models, err := driver.ListModels(ctx) + requireChecks.NoError(err) + requireChecks.NotEmpty(models) + provider.Model = models[0].ID + provider.ReasoningEffort = models[0].DefaultReasoningEffort + config := Config{Enabled: true, Provider: ProviderSelection{Name: "codex"}, Providers: map[string]ProviderConfig{"codex": provider}} + config.ApplyDefaults() + profile, err := config.Profile() + requireChecks.NoError(err) + driver, err = NewCodexAppServerDriverWithAuthHome(provider, starter, gate, authHome) + requireChecks.NoError(err) + request := StructuredRequest{ + ProgramID: "synthetic", ProgramVersion: "1", + Sources: []SourceDescriptor{{Class: SourceConversationText, ObservedOn: "2026-09-23"}}, + InputText: `{"packet":"synthetic-only","instruction":"return ok"}`, SchemaName: "result", + JSONSchema: jsontext.Value(`{"type":"object","properties":{"result":{"type":"string","enum":["ok"]}},"required":["result"],"additionalProperties":false}`), + MaxOutputTokens: 64, + } + prepared, err := driver.Prepare(profile, request) + requireChecks.NoError(err) + response, err := driver.GeneratePrepared(ctx, profile, Credential{}, prepared) + requireChecks.NoError(err) + assert.JSONEq(t, `{"result":"ok"}`, string(response.CandidateJSON)) + t.Log("packet-only structured inference completed through the pinned launcher") +} + +func buildCodexProxyBridge(ctx context.Context, t *testing.T) string { + t.Helper() + bridge := filepath.Join(t.TempDir(), "msgvault-codex-bridge") + command := exec.CommandContext(ctx, "go", "build", "-o", bridge, "go.kenn.io/msgvault/cmd/msgvault-codex-bridge") + command.Env = append(os.Environ(), "CGO_ENABLED=0", "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, string(output)) + return bridge +} + +func buildCodexContainmentProbe(ctx context.Context, t *testing.T, workRoot string) { + t.Helper() + probe := filepath.Join(workRoot, "containment-probe") + _, sourceFile, _, ok := runtime.Caller(0) + require.True(t, ok) + source := filepath.Join(filepath.Dir(sourceFile), "testdata", "codex_containment_probe", "main.go") + command := exec.CommandContext(ctx, "go", "build", "-o", probe, source) //nolint:gosec // Fixed test source and owner-only output. + command.Env = append(os.Environ(), "CGO_ENABLED=0", "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, string(output)) +} + +func codexRunContainmentProbe( + ctx context.Context, t *testing.T, client *CodexRPCClient, operation string, targets ...string, +) codexContainmentProbeResult { + t.Helper() + var result codexContainmentProbeResult + argv := append([]string{"/work/containment-probe", operation}, targets...) + timeoutMs := 2000 + if operation == "tls" { + timeoutMs = 10000 + } + err := client.Call(ctx, "command/exec", map[string]any{ + "command": argv, + "cwd": "/work", + "sandboxPolicy": map[string]any{"type": "dangerFullAccess"}, + "timeoutMs": timeoutMs, + "outputBytesCap": 4096, + }, &result) + require.NoError(t, err) + require.Equal(t, 0, result.ExitCode, "stderr: %q", result.Stderr) + assert.Empty(t, result.Stderr) + t.Logf("command/exec argv=%q cwd=/work sandboxPolicy=dangerFullAccess timeoutMs=%d -> exitCode=%d stdout=%q stderr=%q", + argv, timeoutMs, result.ExitCode, strings.TrimSpace(result.Stdout), strings.TrimSpace(result.Stderr)) + return result +} diff --git a/internal/peoplesweep/codex_enrollment_client.go b/internal/peoplesweep/codex_enrollment_client.go new file mode 100644 index 000000000..87a3b482a --- /dev/null +++ b/internal/peoplesweep/codex_enrollment_client.go @@ -0,0 +1,11 @@ +package peoplesweep + +import "context" + +// CodexEnrollment is the model-less daemon enrollment surface. A draft auth +// home belongs to one caller; its owner serializes login, model listing, and +// later inference for that home. +type CodexEnrollment interface { + StartDeviceLogin(ctx context.Context, present func(DeviceLogin) error) error + ListModels(ctx context.Context) ([]CodexModel, error) +} diff --git a/internal/peoplesweep/codex_enrollment_client_linux.go b/internal/peoplesweep/codex_enrollment_client_linux.go new file mode 100644 index 000000000..fae5944f9 --- /dev/null +++ b/internal/peoplesweep/codex_enrollment_client_linux.go @@ -0,0 +1,85 @@ +//go:build linux + +package peoplesweep + +import ( + "context" + "errors" + "fmt" + "time" +) + +type codexEnrollmentClient struct { + driver *CodexAppServerDriver + launcher codexBoundLauncher + authHome string +} + +// NewCodexEnrollmentClient uses the enrollment-only executable pin and built helper. +// authHome must be a dedicated, owner-only directory created by the daemon. +func NewCodexEnrollmentClient(executable, authHome string, timeout time.Duration) (CodexEnrollment, error) { + return NewCodexEnrollmentClientWithDependencies(executable, authHome, timeout, NewCodexCommandStarter(), codexEnrollmentIsolationGate{}) +} + +// NewCodexEnrollmentClientWithDependencies permits an explicit launch gate and +// starter for containment probes. Production callers use NewCodexEnrollmentClient. +func NewCodexEnrollmentClientWithDependencies(executable, authHome string, timeout time.Duration, starter CommandStarter, gate CodexIsolationGate) (CodexEnrollment, error) { + if executable == "" || timeout <= 0 || timeout > 15*time.Minute { + return nil, errors.New("codex enrollment requires executable and bounded timeout") + } + if starter == nil || gate == nil { + return nil, errors.New("codex enrollment requires command starter and isolation gate") + } + if err := codexPrivateAuthHome(authHome); err != nil { + return nil, err + } + launcher := codexBoundLauncher{starter: starter, gate: gate, proxy: defaultCodexServiceProxy()} + if launcher.proxy == nil { + return nil, ErrCodexProxyUnreleased + } + driver := &CodexAppServerDriver{ + Config: ProviderConfig{Executable: executable, ExecutionBoundary: CodexExecutionBoundaryV1, RequestTimeout: timeout}, + launcher: launcher, authHome: authHome, + } + return &codexEnrollmentClient{driver: driver, launcher: launcher, authHome: authHome}, nil +} + +func (e *codexEnrollmentClient) StartDeviceLogin(ctx context.Context, present func(DeviceLogin) error) (retErr error) { + if present == nil { + return errors.New("codex device login presenter is required") + } + operationCtx, cancel := context.WithTimeout(ctx, e.driver.Config.RequestTimeout) + defer cancel() + releaseAuth, err := lockCodexAuthOperation(operationCtx, e.authHome) + if err != nil { + return err + } + defer releaseAuth() + attestation, err := e.driver.attest(operationCtx) + if err != nil { + return err + } + process, err := e.launcher.StartLogin(operationCtx, attestation, e.authHome) + if err != nil { + return errors.Join(fmt.Errorf("start codex enrollment process: %w", err), attestation.Close()) + } + client := &CodexRPCClient{Process: process} + defer func() { + retErr = errors.Join(retErr, finishCodexProcess(operationCtx, process, client, retErr != nil), attestation.Close()) + }() + if err := runCodexDeviceLogin(operationCtx, client, present); err != nil { + return err + } + if err := operationCtx.Err(); err != nil { + return err + } + owned, ok := process.(*codexOwnedProcess) + if !ok { + return errors.New("codex enrollment process has no owned work root") + } + return owned.CommitLoginAuth(e.authHome) +} + +func (e *codexEnrollmentClient) ListModels(ctx context.Context) ([]CodexModel, error) { + return e.driver.ListModels(ctx) +} diff --git a/internal/peoplesweep/codex_enrollment_client_linux_test.go b/internal/peoplesweep/codex_enrollment_client_linux_test.go new file mode 100644 index 000000000..31eaa38e1 --- /dev/null +++ b/internal/peoplesweep/codex_enrollment_client_linux_test.go @@ -0,0 +1,216 @@ +//go:build linux + +package peoplesweep_test + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/peoplesweep" +) + +func TestCodexEnrollmentCommitsOnlySuccessfulLoginAndListsWithDedicatedHome(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + authHome := t.TempDir() + requireChecks.NoError(os.Chmod(authHome, 0o700)) + syntheticAuth := syntheticCodexAuth("user-one", "workspace-one", "refresh") + start := func(reader *bufio.Reader, stdout io.Writer, _ io.Writer) error { + if _, err := reader.ReadBytes('\n'); err != nil { + return fmt.Errorf("read initialize request: %w", err) + } + if err := writeRPCFrame(stdout, map[string]any{"id": 1, "result": map[string]any{}}); err != nil { + return err + } + if _, err := reader.ReadBytes('\n'); err != nil { + return fmt.Errorf("read initialized notification: %w", err) + } + if _, err := reader.ReadBytes('\n'); err != nil { + return fmt.Errorf("read device login request: %w", err) + } + if err := writeRPCFrame(stdout, map[string]any{"id": 2, "result": map[string]any{ + "type": "chatgptDeviceCode", "loginId": "synthetic-login", + "verificationUrl": "https://auth.example.test/device", "userCode": "ABCD-1234", + }}); err != nil { + return err + } + return nil + } + var workRoot string + launches := 0 + starter := &recordingCodexStarter{t: t, inspect: func(dir string) { + launches++ + workRoot = dir + if launches == 2 { + contents, err := os.ReadFile(filepath.Join(dir, ".codex", "auth.json")) + require.NoError(t, err) + assert.Equal(t, syntheticAuth, contents) + } + }, scripts: []func(*bufio.Reader, io.Writer, io.Writer) error{ + func(reader *bufio.Reader, stdout, stderr io.Writer) error { + if err := start(reader, stdout, stderr); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(workRoot, ".codex", "auth.json"), syntheticAuth, 0o600); err != nil { + return err + } + return writeRPCFrame(stdout, map[string]any{"method": "account/login/completed", "params": map[string]any{"success": true, "loginId": "synthetic-login"}}) + }, + func(reader *bufio.Reader, stdout, _ io.Writer) error { + if _, err := reader.ReadBytes('\n'); err != nil { + return fmt.Errorf("read model initialize request: %w", err) + } + if err := writeRPCFrame(stdout, map[string]any{"id": 1, "result": map[string]any{}}); err != nil { + return err + } + if _, err := reader.ReadBytes('\n'); err != nil { + return fmt.Errorf("read model initialized notification: %w", err) + } + if _, err := reader.ReadBytes('\n'); err != nil { + return fmt.Errorf("read model list request: %w", err) + } + return writeRPCFrame(stdout, map[string]any{"id": 2, "result": map[string]any{ + "data": []any{map[string]any{"id": "gpt-test", "model": "gpt-test", "displayName": "Test Model", "defaultReasoningEffort": "medium", "supportedReasoningEfforts": []any{map[string]any{"reasoningEffort": "medium", "description": "Balanced"}}}}, "nextCursor": nil, + }}) + }, + }} + client, err := peoplesweep.NewCodexEnrollmentClientWithDependencies("codex", authHome, time.Second, starter, &recordingCodexGate{}) + requireChecks.NoError(err) + err = client.StartDeviceLogin(t.Context(), func(login peoplesweep.DeviceLogin) error { + assert.Equal(t, "ABCD-1234", login.UserCode) + assert.NoFileExists(t, filepath.Join(authHome, "auth.json")) + return nil + }) + requireChecks.NoError(err) + assertChecks.NoDirExists(starter.records[0].dir) + contents, err := os.ReadFile(filepath.Join(authHome, "auth.json")) + requireChecks.NoError(err) + assertChecks.Equal(syntheticAuth, contents) + models, err := client.ListModels(t.Context()) + requireChecks.NoError(err) + assertChecks.Equal([]peoplesweep.CodexModel{{ID: "gpt-test", DisplayName: "Test Model", DefaultReasoningEffort: "medium", SupportedEfforts: []string{"medium"}}}, models) + assertChecks.Equal(int64(2), starter.proxyStarts.Load()) +} + +func TestCodexEnrollmentRejectsPublicAuthHome(t *testing.T) { + authHome := t.TempDir() + require.NoError(t, os.Chmod(authHome, 0o755)) + client, err := peoplesweep.NewCodexEnrollmentClientWithDependencies("codex", authHome, time.Second, &recordingCodexStarter{t: t}, &recordingCodexGate{}) + require.ErrorContains(t, err, "private") + assert.Nil(t, client) +} + +func TestCodexEnrollmentFailedLoginLeavesNoDedicatedCredential(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + authHome := t.TempDir() + requireChecks.NoError(os.Chmod(authHome, 0o700)) + starter := &recordingCodexStarter{t: t, scripts: []func(*bufio.Reader, io.Writer, io.Writer) error{ + func(reader *bufio.Reader, stdout, _ io.Writer) error { + if _, err := reader.ReadBytes('\n'); err != nil { + return fmt.Errorf("read failed-login initialize request: %w", err) + } + if err := writeRPCFrame(stdout, map[string]any{"id": 1, "result": map[string]any{}}); err != nil { + return err + } + if _, err := reader.ReadBytes('\n'); err != nil { + return fmt.Errorf("read failed-login initialized notification: %w", err) + } + if _, err := reader.ReadBytes('\n'); err != nil { + return fmt.Errorf("read failed-login start request: %w", err) + } + return writeRPCFrame(stdout, map[string]any{"id": 2, "result": map[string]any{"type": "chatgptDeviceCode", "loginId": "synthetic-login", "verificationUrl": "https://auth.example.test/device", "userCode": "ABCD-1234"}}) + }, + }} + client, err := peoplesweep.NewCodexEnrollmentClientWithDependencies("codex", authHome, time.Second, starter, &recordingCodexGate{}) + requireChecks.NoError(err) + err = client.StartDeviceLogin(t.Context(), func(peoplesweep.DeviceLogin) error { return errors.New("presenter cancelled") }) + requireChecks.ErrorContains(err, "presenter cancelled") + assertChecks.NoFileExists(filepath.Join(authHome, "auth.json")) + assertChecks.NoDirExists(starter.records[0].dir) +} + +func TestCodexEnrollmentAccountSwitchPreservesOldAuthUntilSuccessfulLogin(t *testing.T) { + for _, tc := range []struct { + name string + accept bool + }{ + {name: "successful switch", accept: true}, + {name: "cancelled switch", accept: false}, + } { + t.Run(tc.name, func(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + authHome := t.TempDir() + requireChecks.NoError(os.Chmod(authHome, 0o700)) + oldAuth := syntheticCodexAuth("user-one", "workspace-one", "old") + newAuth := syntheticCodexAuth("user-two", "workspace-two", "new") + requireChecks.NoError(os.WriteFile(filepath.Join(authHome, "auth.json"), oldAuth, 0o600)) + var workRoot string + starter := &recordingCodexStarter{t: t, inspect: func(dir string) { + workRoot = dir + assert.NoFileExists(t, filepath.Join(dir, ".codex", "auth.json")) + }, scripts: []func(*bufio.Reader, io.Writer, io.Writer) error{ + func(reader *bufio.Reader, stdout, _ io.Writer) error { + if _, err := reader.ReadBytes('\n'); err != nil { + return fmt.Errorf("read repeat-login initialize request: %w", err) + } + if err := writeRPCFrame(stdout, map[string]any{"id": 1, "result": map[string]any{}}); err != nil { + return err + } + if _, err := reader.ReadBytes('\n'); err != nil { + return fmt.Errorf("read repeat-login initialized notification: %w", err) + } + if _, err := reader.ReadBytes('\n'); err != nil { + return fmt.Errorf("read repeat-login start request: %w", err) + } + if err := writeRPCFrame(stdout, map[string]any{"id": 2, "result": map[string]any{"type": "chatgptDeviceCode", "loginId": "synthetic-login", "verificationUrl": "https://auth.example.test/device", "userCode": "ABCD-1234"}}); err != nil { + return err + } + if !tc.accept { + return nil + } + if err := os.WriteFile(filepath.Join(workRoot, ".codex", "auth.json"), newAuth, 0o600); err != nil { + return err + } + return writeRPCFrame(stdout, map[string]any{"method": "account/login/completed", "params": map[string]any{"success": true, "loginId": "synthetic-login"}}) + }, + }} + client, err := peoplesweep.NewCodexEnrollmentClientWithDependencies("codex", authHome, time.Second, starter, &recordingCodexGate{}) + requireChecks.NoError(err) + err = client.StartDeviceLogin(t.Context(), func(peoplesweep.DeviceLogin) error { + contents, readErr := os.ReadFile(filepath.Join(authHome, "auth.json")) + require.NoError(t, readErr) + assert.Equal(t, oldAuth, contents) + if !tc.accept { + return errors.New("cancelled") + } + return nil + }) + if tc.accept { + requireChecks.NoError(err) + } else { + requireChecks.ErrorContains(err, "cancelled") + } + contents, err := os.ReadFile(filepath.Join(authHome, "auth.json")) + requireChecks.NoError(err) + if tc.accept { + assertChecks.Equal(newAuth, contents) + } else { + assertChecks.Equal(oldAuth, contents) + } + info, err := os.Lstat(filepath.Join(authHome, "auth.json")) + requireChecks.NoError(err) + assertChecks.Equal(os.FileMode(0o600), info.Mode().Perm()) + assertChecks.NoDirExists(workRoot) + }) + } +} diff --git a/internal/peoplesweep/codex_enrollment_client_unsupported.go b/internal/peoplesweep/codex_enrollment_client_unsupported.go new file mode 100644 index 000000000..2a7c0d441 --- /dev/null +++ b/internal/peoplesweep/codex_enrollment_client_unsupported.go @@ -0,0 +1,12 @@ +//go:build !linux + +package peoplesweep + +import ( + "errors" + "time" +) + +func NewCodexEnrollmentClient(string, string, time.Duration) (CodexEnrollment, error) { + return nil, errors.New("codex enrollment containment is unavailable on this platform") +} diff --git a/internal/peoplesweep/codex_enrollment_gate_linux.go b/internal/peoplesweep/codex_enrollment_gate_linux.go new file mode 100644 index 000000000..9cbc34f8c --- /dev/null +++ b/internal/peoplesweep/codex_enrollment_gate_linux.go @@ -0,0 +1,24 @@ +//go:build linux + +package peoplesweep + +import "context" + +// codexEnrollmentIsolationGate admits the reviewed native artifact only for +// the narrow enrollment client. It does not populate the inference registry. +type codexEnrollmentIsolationGate struct{} + +var codexEnrollmentAttestations = map[CodexReleaseKey]CodexAttestation{ + {ExecutableSHA256: "78a11f06e0a2dda42d13fba1d50dc62e8cbdb2d5f69789722f4d4d99b5cdbe30", ExecutionBoundary: CodexExecutionBoundaryV1}: { + Version: "codex-cli 0.156.0", ExecutableSHA256: "78a11f06e0a2dda42d13fba1d50dc62e8cbdb2d5f69789722f4d4d99b5cdbe30", + ExecutionBoundary: CodexExecutionBoundaryV1, LaunchArtifact: CodexLaunchArtifactNativeStandaloneV1, + }, +} + +func (codexEnrollmentIsolationGate) Verify(ctx context.Context, executable, boundary string) (CodexAttestation, error) { + return verifyReleasedCodexIsolation(ctx, executable, boundary, codexEnrollmentAttestations) +} + +func (codexEnrollmentIsolationGate) ReverifyForLaunch(attestation CodexAttestation) error { + return reverifyReleasedCodexIsolation(attestation, codexEnrollmentAttestations) +} diff --git a/internal/peoplesweep/codex_enrollment_gate_linux_test.go b/internal/peoplesweep/codex_enrollment_gate_linux_test.go new file mode 100644 index 000000000..9f0f8cde4 --- /dev/null +++ b/internal/peoplesweep/codex_enrollment_gate_linux_test.go @@ -0,0 +1,62 @@ +//go:build linux + +package peoplesweep + +import ( + "context" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCodexEnrollmentPinAllowsOnlyModelLessEnrollment(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + artifact := os.Getenv("MSGVAULT_CODEX_PINNED_EXECUTABLE") + if artifact == "" { + t.Skip("set MSGVAULT_CODEX_PINNED_EXECUTABLE to the pinned native artifact") + } + authHome := t.TempDir() + requireChecks.NoError(os.Chmod(authHome, 0o700)) + client, err := NewCodexEnrollmentClient(artifact, authHome, time.Minute) + requireChecks.NoError(err) + enrollment, ok := client.(*codexEnrollmentClient) + requireChecks.True(ok) + attestation, err := enrollment.driver.attest(context.Background()) + requireChecks.NoError(err) + assertChecks.Equal("codex-cli 0.156.0", attestation.Version) + requireChecks.NoError(attestation.Close()) + assertChecks.Empty(releasedCodexAttestations) + _, err = NewReleasedCodexIsolationGate().Verify(context.Background(), artifact, CodexExecutionBoundaryV1) + requireChecks.ErrorIs(err, ErrCodexIsolationUnreleased) +} + +func TestCodexEnrollmentPinnedLauncherStartsEmptyLoginSession(t *testing.T) { + requireChecks := require.New(t) + artifact := os.Getenv("MSGVAULT_CODEX_PINNED_EXECUTABLE") + if artifact == "" { + t.Skip("set MSGVAULT_CODEX_PINNED_EXECUTABLE to the pinned native artifact") + } + authHome := t.TempDir() + requireChecks.NoError(os.Chmod(authHome, 0o700)) + client, err := NewCodexEnrollmentClient(artifact, authHome, time.Minute) + requireChecks.NoError(err) + enrollment, ok := client.(*codexEnrollmentClient) + requireChecks.True(ok) + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second) + defer cancel() + attestation, err := enrollment.driver.attest(ctx) + requireChecks.NoError(err) + defer func() { require.NoError(t, attestation.Close()) }() + process, err := enrollment.launcher.StartLogin(ctx, attestation, authHome) + requireChecks.NoError(err) + rpc := &CodexRPCClient{Process: process} + defer func() { require.NoError(t, finishCodexProcess(ctx, process, rpc, true)) }() + var initialized map[string]any + requireChecks.NoError(rpc.Call(ctx, "initialize", codexInitializeRequest().Params, &initialized)) + requireChecks.NoError(rpc.Notify(ctx, "initialized", nil)) + assert.NoFileExists(t, authHome+"/auth.json") +} diff --git a/internal/peoplesweep/codex_enrollment_linux.go b/internal/peoplesweep/codex_enrollment_linux.go new file mode 100644 index 000000000..a11e07fa5 --- /dev/null +++ b/internal/peoplesweep/codex_enrollment_linux.go @@ -0,0 +1,188 @@ +//go:build linux + +package peoplesweep + +import ( + "context" + "crypto/sha256" + "encoding/json/jsontext" + "errors" + "io" + "os" + "path/filepath" +) + +func (l codexBoundLauncher) StartLogin(ctx context.Context, attestation CodexAttestation, authHome string) (RPCProcess, error) { + if err := codexPrivateAuthHome(authHome); err != nil { + return nil, err + } + if l.proxy == nil { + return nil, ErrCodexProxyUnreleased + } + if _, err := os.Lstat(filepath.Join(authHome, "auth.json")); err == nil { + contents, _, err := readPrivateCodexAuth(authHome) + if err != nil { + return nil, err + } + if _, err := codexAccountIdentityFromAuth(contents); err != nil { + return nil, err + } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, errors.New("codex dedicated auth.json cannot be inspected") + } + return l.start(ctx, attestation, "", true) +} + +// CommitLoginAuth is called only after the exact successful device-login +// notification, before the disposable process root is removed. A repeat login +// atomically replaces a private dedicated credential after identity checks. +func (p *codexOwnedProcess) CommitLoginAuth(authHome string) error { + if err := codexPrivateAuthHome(authHome); err != nil { + return err + } + if !filepath.IsAbs(p.workRoot) || filepath.Clean(p.workRoot) != p.workRoot { + return errors.New("codex login work root is invalid") + } + workInfo, err := os.Lstat(p.workRoot) + if err != nil || !workInfo.IsDir() || workInfo.Mode().Perm()&0o077 != 0 || !codexAuthOwnedByDaemon(workInfo) { + return errors.New("codex login work root is unavailable") + } + stagedInfo, err := os.Lstat(filepath.Join(p.workRoot, ".codex")) + if err != nil || !stagedInfo.IsDir() || stagedInfo.Mode().Perm()&0o077 != 0 || !codexAuthOwnedByDaemon(stagedInfo) { + return errors.New("codex login credential directory is unavailable") + } + root, err := os.OpenRoot(filepath.Join(p.workRoot, ".codex")) + if err != nil { + return errors.New("open codex login credential directory") + } + defer func() { _ = root.Close() }() + info, err := root.Lstat("auth.json") + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0o077 != 0 || + info.Size() <= 0 || info.Size() > 1<<20 || !codexAuthOwnedByDaemon(info) { + return errors.New("codex login auth.json must be a private regular file under 1 MiB") + } + source, err := root.Open("auth.json") + if err != nil { + return errors.New("open codex login auth.json") + } + defer func() { _ = source.Close() }() + opened, err := source.Stat() + if err != nil || !os.SameFile(info, opened) || !codexAuthOwnedByDaemon(opened) { + return errors.New("codex login auth.json changed during open") + } + contents, err := io.ReadAll(io.LimitReader(source, 1<<20+1)) + if err != nil || len(contents) == 0 || len(contents) > 1<<20 || !jsontext.Value(contents).IsValid() { + return errors.New("codex login auth.json is invalid") + } + finalInfo, err := source.Stat() + if err != nil || !os.SameFile(opened, finalInfo) || finalInfo.Size() != int64(len(contents)) || + !finalInfo.ModTime().Equal(opened.ModTime()) { + return errors.New("codex login auth.json changed during read") + } + if _, err := codexAccountIdentityFromAuth(contents); err != nil { + return err + } + destination := filepath.Join(authHome, "auth.json") + var previous []byte + var previousInfo os.FileInfo + if _, err := os.Lstat(destination); err == nil { + previous, previousInfo, err = readPrivateCodexAuth(authHome) + if err != nil { + return err + } + if _, err := codexAccountIdentityFromAuth(previous); err != nil { + return err + } + } else if !errors.Is(err, os.ErrNotExist) { + return errors.New("codex dedicated auth.json cannot be inspected") + } + temp, err := os.CreateTemp(authHome, ".auth-draft-") + if err != nil { + return errors.New("create private codex login draft") + } + defer func() { _ = os.Remove(temp.Name()) }() + if err := temp.Chmod(0o600); err != nil { + _ = temp.Close() + return errors.New("secure codex login draft") + } + _, writeErr := temp.Write(contents) + syncErr := temp.Sync() + closeErr := temp.Close() + if writeErr != nil || syncErr != nil || closeErr != nil { + return errors.New("write codex login draft") + } + if previousInfo == nil { + if err := os.Link(temp.Name(), destination); err != nil { + return errors.New("commit codex login auth.json") + } + } else { + current, currentInfo, err := readPrivateCodexAuth(authHome) + if err != nil || !os.SameFile(previousInfo, currentInfo) || sha256.Sum256(previous) != sha256.Sum256(current) { + return ErrCodexAuthSourceChanged + } + if err := os.Rename(temp.Name(), destination); err != nil { + return errors.New("replace codex login auth.json") + } + } + rollback := func(cause error) error { + return errors.Join(cause, restorePreviousCodexAuth(authHome, previous, previousInfo)) + } + if err := os.Remove(temp.Name()); err != nil { + if !errors.Is(err, os.ErrNotExist) { + return rollback(errors.New("remove codex login draft after commit")) + } + } + directory, err := os.Open(authHome) + if err != nil { + return rollback(errors.New("open codex dedicated auth home after commit")) + } + syncErr = directory.Sync() + closeErr = directory.Close() + if syncErr != nil || closeErr != nil { + return rollback(errors.New("sync codex dedicated auth home")) + } + return nil +} + +func restorePreviousCodexAuth(authHome string, previous []byte, previousInfo os.FileInfo) error { + destination := filepath.Join(authHome, "auth.json") + if previousInfo == nil { + return os.Remove(destination) + } + temp, err := os.CreateTemp(authHome, ".auth-rollback-") + if err != nil { + return errors.New("restore previous codex auth.json") + } + defer func() { _ = os.Remove(temp.Name()) }() + _, writeErr := temp.Write(previous) + modeErr := temp.Chmod(previousInfo.Mode().Perm()) + syncErr := temp.Sync() + closeErr := temp.Close() + if writeErr != nil || modeErr != nil || syncErr != nil || closeErr != nil { + return errors.New("restore previous codex auth.json") + } + if err := os.Rename(temp.Name(), destination); err != nil { + return errors.New("restore previous codex auth.json") + } + directory, err := os.Open(authHome) + if err != nil { + return errors.New("sync restored codex auth.json") + } + syncErr = directory.Sync() + closeErr = directory.Close() + if syncErr != nil || closeErr != nil { + return errors.New("sync restored codex auth.json") + } + return nil +} + +func codexPrivateAuthHome(authHome string) error { + if !filepath.IsAbs(authHome) || filepath.Clean(authHome) != authHome { + return errors.New("codex dedicated auth home must be an absolute clean path") + } + info, err := os.Lstat(authHome) //nolint:gosec // The daemon selects this absolute, clean path; ownership and mode are checked below. + if err != nil || !info.IsDir() || info.Mode().Perm()&0o077 != 0 || !codexAuthOwnedByDaemon(info) { + return errors.New("codex dedicated auth home must be private and daemon-owned") + } + return nil +} diff --git a/internal/peoplesweep/codex_enrollment_linux_test.go b/internal/peoplesweep/codex_enrollment_linux_test.go new file mode 100644 index 000000000..362065c80 --- /dev/null +++ b/internal/peoplesweep/codex_enrollment_linux_test.go @@ -0,0 +1,121 @@ +//go:build linux + +package peoplesweep + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCodexLoginAuthCommitRequiresPrivateFileAndKeepsDedicatedHome(t *testing.T) { + for _, tc := range []struct { + name string + authMode os.FileMode + symlink bool + preexists bool + wantError string + }{ + {name: "valid", authMode: 0o600}, + {name: "public file", authMode: 0o644, wantError: "private regular file"}, + {name: "symlink", authMode: 0o600, symlink: true, wantError: "private regular file"}, + {name: "invalid existing auth", authMode: 0o600, preexists: true, wantError: "unsafe"}, + } { + t.Run(tc.name, func(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + workRoot := t.TempDir() + requireChecks.NoError(os.Chmod(workRoot, 0o700)) + stagedDir := filepath.Join(workRoot, ".codex") + requireChecks.NoError(os.Mkdir(stagedDir, 0o700)) + authFile := filepath.Join(stagedDir, "auth.json") + if tc.symlink { + target := filepath.Join(workRoot, "unrelated.json") + requireChecks.NoError(os.WriteFile(target, syntheticCodexProbeAuth("user-one", "workspace-one", "refresh"), 0o600)) + requireChecks.NoError(os.Symlink(target, authFile)) + } else { + requireChecks.NoError(os.WriteFile(authFile, syntheticCodexProbeAuth("user-one", "workspace-one", "refresh"), tc.authMode)) + } + authHome := t.TempDir() + requireChecks.NoError(os.Chmod(authHome, 0o700)) + if tc.preexists { + requireChecks.NoError(os.WriteFile(filepath.Join(authHome, "auth.json"), []byte("EXISTING"), 0o600)) + } + process := &codexOwnedProcess{workRoot: workRoot} + err := process.CommitLoginAuth(authHome) + if tc.wantError != "" { + requireChecks.ErrorContains(err, tc.wantError) + if tc.preexists { + contents, readErr := os.ReadFile(filepath.Join(authHome, "auth.json")) + requireChecks.NoError(readErr) + assertChecks.Equal("EXISTING", string(contents)) + } else { + assertChecks.NoFileExists(filepath.Join(authHome, "auth.json")) + } + return + } + requireChecks.NoError(err) + requireChecks.NoError(process.cleanup()) + assertChecks.NoDirExists(workRoot) + contents, err := os.ReadFile(filepath.Join(authHome, "auth.json")) + requireChecks.NoError(err) + assertChecks.Equal(syntheticCodexProbeAuth("user-one", "workspace-one", "refresh"), contents) + info, err := os.Stat(filepath.Join(authHome, "auth.json")) + requireChecks.NoError(err) + assertChecks.Equal(os.FileMode(0o600), info.Mode().Perm()) + }) + } +} + +func TestCodexLoginReplacesPrivateAuthAfterValidAccountSwitch(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + workRoot := t.TempDir() + requireChecks.NoError(os.Chmod(workRoot, 0o700)) + stagedDir := filepath.Join(workRoot, ".codex") + requireChecks.NoError(os.Mkdir(stagedDir, 0o700)) + newAuth := syntheticCodexProbeAuth("user-two", "workspace-two", "new-refresh") + requireChecks.NoError(os.WriteFile(filepath.Join(stagedDir, "auth.json"), newAuth, 0o600)) + authHome := t.TempDir() + requireChecks.NoError(os.Chmod(authHome, 0o700)) + oldAuth := syntheticCodexProbeAuth("user-one", "workspace-one", "old-refresh") + requireChecks.NoError(os.WriteFile(filepath.Join(authHome, "auth.json"), oldAuth, 0o600)) + process := &codexOwnedProcess{workRoot: workRoot} + requireChecks.NoError(process.CommitLoginAuth(authHome)) + contents, err := os.ReadFile(filepath.Join(authHome, "auth.json")) + requireChecks.NoError(err) + assertChecks.Equal(newAuth, contents) + info, err := os.Lstat(filepath.Join(authHome, "auth.json")) + requireChecks.NoError(err) + assertChecks.Equal(os.FileMode(0o600), info.Mode().Perm()) +} + +func TestCodexLoginRejectedReplacementKeepsOldAuth(t *testing.T) { + for _, tc := range []struct { + name string + candidate []byte + oldMode os.FileMode + }{ + {name: "invalid candidate identity", candidate: []byte(`{"synthetic":true}`), oldMode: 0o600}, + {name: "public old auth", candidate: syntheticCodexProbeAuth("user-two", "workspace-two", "new"), oldMode: 0o644}, + } { + t.Run(tc.name, func(t *testing.T) { + requireChecks := require.New(t) + workRoot := t.TempDir() + requireChecks.NoError(os.Chmod(workRoot, 0o700)) + requireChecks.NoError(os.Mkdir(filepath.Join(workRoot, ".codex"), 0o700)) + requireChecks.NoError(os.WriteFile(filepath.Join(workRoot, ".codex", "auth.json"), tc.candidate, 0o600)) + authHome := t.TempDir() + requireChecks.NoError(os.Chmod(authHome, 0o700)) + old := syntheticCodexProbeAuth("user-one", "workspace-one", "old") + requireChecks.NoError(os.WriteFile(filepath.Join(authHome, "auth.json"), old, tc.oldMode)) + requireChecks.Error((&codexOwnedProcess{workRoot: workRoot}).CommitLoginAuth(authHome)) + contents, err := os.ReadFile(filepath.Join(authHome, "auth.json")) + requireChecks.NoError(err) + assert.Equal(t, old, contents) + }) + } +} diff --git a/internal/peoplesweep/codex_isolation.go b/internal/peoplesweep/codex_isolation.go index 7bdd365f6..2481116e6 100644 --- a/internal/peoplesweep/codex_isolation.go +++ b/internal/peoplesweep/codex_isolation.go @@ -174,7 +174,7 @@ func reverifyReleasedCodexIsolation( } func snapshotCodexExecutable(sourcePath string) (_ *verifiedCodexExecutable, _ string, retErr error) { - source, err := os.Open(sourcePath) + source, err := os.Open(sourcePath) //nolint:gosec // The resolved, pinned executable path is intentionally opened for the snapshot. if err != nil { return nil, "", errors.New("open codex executable") } @@ -258,7 +258,7 @@ func resolveCodexExecutable(executable string) (string, error) { } func hashCodexExecutable(executable string) (string, error) { - file, err := os.Open(executable) + file, err := os.Open(executable) //nolint:gosec // The selected executable path is intentionally read to verify its digest. if err != nil { return "", errors.New("hash codex executable") } diff --git a/internal/peoplesweep/codex_isolation_linux_test.go b/internal/peoplesweep/codex_isolation_linux_test.go index 27bf6edf9..b58649cb6 100644 --- a/internal/peoplesweep/codex_isolation_linux_test.go +++ b/internal/peoplesweep/codex_isolation_linux_test.go @@ -3,11 +3,17 @@ package peoplesweep import ( + "context" + "fmt" + "io" + "net" "os" "os/exec" "path/filepath" "strconv" + "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -48,8 +54,282 @@ func TestCodexRegisteredExecutableRejectsAdjacentDynamicDependency(t *testing.T) assert.NoFileExists(t, marker) } +func TestCodexPinnedArtifactRunsInIsolatedNetworkNamespace(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + path := os.Getenv("MSGVAULT_CODEX_PINNED_EXECUTABLE") + if path == "" { + t.Skip("set MSGVAULT_CODEX_PINNED_EXECUTABLE to the pinned native artifact") + } + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second) + defer cancel() + process, err := NewCodexCommandStarter().Start( + ctx, CodexExecutable{verifiedPath: path}, codexAppServerArgs, nil, t.TempDir(), + ) + requireChecks.NoError(err) + client := &CodexRPCClient{Process: process} + defer func() { require.NoError(t, finishCodexProcess(ctx, process, client, true)) }() + var initialized map[string]any + initErr := client.Call(ctx, "initialize", codexInitializeRequest().Params, &initialized) + if initErr != nil { + stderr, _ := io.ReadAll(io.LimitReader(process.Stderr(), 4096)) + waitErr := process.Wait() + requireChecks.NoError(initErr, "stderr: %q; wait: %v", stderr, waitErr) + } + requireChecks.NoError(client.Notify(ctx, "initialized", nil)) + var models codexModelListResult + requireChecks.NoError(client.Call(ctx, "model/list", codexModelListRequest(2).Params, &models)) + assertChecks.NotEmpty(models.Data) + execProcess, ok := process.(*execRPCProcess) + requireChecks.True(ok) + actual, err := codexProcessNetworkNamespaces(execProcess.command.Process.Pid) + requireChecks.NoError(err) + host, err := os.Readlink("/proc/self/ns/net") + requireChecks.NoError(err) + for _, namespace := range actual { + if namespace != host { + return + } + } + assertChecks.Fail("Codex remained in the host network namespace") +} + +func TestCodexPinnedArtifactDoesNotInheritAmbientAuthHome(t *testing.T) { + requireChecks := require.New(t) + path := os.Getenv("MSGVAULT_CODEX_PINNED_EXECUTABLE") + if path == "" { + t.Skip("set MSGVAULT_CODEX_PINNED_EXECUTABLE to the pinned native artifact") + } + ordinaryHome := t.TempDir() + requireChecks.NoError(os.WriteFile(filepath.Join(ordinaryHome, "auth.json"), []byte("{}"), 0o600)) + requireChecks.NoError(os.WriteFile(filepath.Join(ordinaryHome, "sentinel.txt"), []byte("SYNTHETIC_OUTSIDE_PACKET_ROOT"), 0o600)) + t.Setenv("CODEX_HOME", ordinaryHome) + digest, err := hashCodexExecutable(path) + requireChecks.NoError(err) + registry := map[CodexReleaseKey]CodexAttestation{ + {ExecutableSHA256: digest, ExecutionBoundary: CodexExecutionBoundaryV1}: { + Version: "codex-cli 0.156.0", ExecutableSHA256: digest, + ExecutionBoundary: CodexExecutionBoundaryV1, LaunchArtifact: CodexLaunchArtifactNativeStandaloneV1, + }, + } + launcher := codexBoundLauncher{ + gate: injectedReleasedCodexGate{registry: registry}, starter: NewCodexCommandStarter(), + } + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second) + defer cancel() + attestation, err := launcher.Verify(ctx, path) + requireChecks.NoError(err) + defer func() { require.NoError(t, attestation.Close()) }() + process, err := launcher.Start(ctx, attestation, "") + requireChecks.NoError(err) + client := &CodexRPCClient{Process: process} + defer func() { require.NoError(t, finishCodexProcess(ctx, process, client, true)) }() + var initialized map[string]any + requireChecks.NoError(client.Call(ctx, "initialize", codexInitializeRequest().Params, &initialized)) + owned, ok := process.(*codexOwnedProcess) + requireChecks.True(ok) + execProcess, ok := owned.RPCProcess.(*execRPCProcess) + requireChecks.True(ok) + pids, err := codexProcessTreePIDs(execProcess.command.Process.Pid) + requireChecks.NoError(err) + hostNetwork, err := os.Readlink("/proc/self/ns/net") + requireChecks.NoError(err) + checked := false + for _, pid := range pids { + namespace, err := os.Readlink(filepath.Join("/proc", strconv.Itoa(pid), "ns/net")) + requireChecks.NoError(err) + if namespace == hostNetwork { + continue + } + checked = true + _, err = os.Stat(filepath.Join("/proc", strconv.Itoa(pid), "root/work/.codex/auth.json")) + requireChecks.ErrorIs(err, os.ErrNotExist) + _, err = os.Stat(filepath.Join("/proc", strconv.Itoa(pid), "root", strings.TrimPrefix(ordinaryHome, "/"), "sentinel.txt")) + requireChecks.ErrorIs(err, os.ErrNotExist) + } + requireChecks.True(checked, "no isolated Codex child was observed") +} + +func TestCodexLauncherStagesOnlyExplicitAuthFile(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + artifact := os.Getenv("MSGVAULT_CODEX_PINNED_EXECUTABLE") + if artifact == "" { + t.Skip("set MSGVAULT_CODEX_PINNED_EXECUTABLE to the pinned native artifact") + } + authHome := t.TempDir() + requireChecks.NoError(os.Chmod(authHome, 0o700)) + requireChecks.NoError(os.WriteFile(filepath.Join(authHome, "auth.json"), []byte("{}"), 0o600)) + requireChecks.NoError(os.WriteFile(filepath.Join(authHome, "unrelated.txt"), []byte("SYNTHETIC_UNRELATED_AUTH_FILE"), 0o600)) + ambientHome := t.TempDir() + requireChecks.NoError(os.WriteFile(filepath.Join(ambientHome, "auth.json"), []byte("SYNTHETIC_AMBIENT_AUTH"), 0o600)) + t.Setenv("CODEX_HOME", ambientHome) + digest, err := hashCodexExecutable(artifact) + requireChecks.NoError(err) + registry := map[CodexReleaseKey]CodexAttestation{ + {ExecutableSHA256: digest, ExecutionBoundary: CodexExecutionBoundaryV1}: { + Version: "codex-cli 0.156.0", ExecutableSHA256: digest, + ExecutionBoundary: CodexExecutionBoundaryV1, LaunchArtifact: CodexLaunchArtifactNativeStandaloneV1, + }, + } + launcher := codexBoundLauncher{gate: injectedReleasedCodexGate{registry: registry}, starter: NewCodexCommandStarter()} + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second) + defer cancel() + attestation, err := launcher.Verify(ctx, artifact) + requireChecks.NoError(err) + defer func() { require.NoError(t, attestation.Close()) }() + process, err := launcher.Start(ctx, attestation, authHome) + requireChecks.NoError(err) + client := &CodexRPCClient{Process: process} + defer func() { require.NoError(t, finishCodexProcess(ctx, process, client, true)) }() + var initialized map[string]any + requireChecks.NoError(client.Call(ctx, "initialize", codexInitializeRequest().Params, &initialized)) + owned, ok := process.(*codexOwnedProcess) + requireChecks.True(ok) + execProcess, ok := owned.RPCProcess.(*execRPCProcess) + requireChecks.True(ok) + pids, err := codexProcessTreePIDs(execProcess.command.Process.Pid) + requireChecks.NoError(err) + hostNetwork, err := os.Readlink("/proc/self/ns/net") + requireChecks.NoError(err) + checked := false + for _, pid := range pids { + namespace, err := os.Readlink(filepath.Join("/proc", strconv.Itoa(pid), "ns/net")) + requireChecks.NoError(err) + if namespace == hostNetwork { + continue + } + checked = true + root := filepath.Join("/proc", strconv.Itoa(pid), "root/work") + contents, err := os.ReadFile(filepath.Join(root, ".codex/auth.json")) + requireChecks.NoError(err) + assertChecks.Equal("{}", string(contents)) + assertChecks.NoFileExists(filepath.Join(root, "unrelated.txt")) + assertChecks.NoFileExists(filepath.Join(root, ".codex/unrelated.txt")) + } + requireChecks.True(checked, "no isolated Codex child was observed") + contents, err := os.ReadFile(filepath.Join(authHome, "auth.json")) + requireChecks.NoError(err) + assertChecks.Equal("{}", string(contents)) +} + +func codexProcessTreePIDs(pid int) ([]int, error) { + seen := map[int]struct{}{} + var pids []int + var visit func(int) error + visit = func(current int) error { + if _, ok := seen[current]; ok { + return nil + } + seen[current] = struct{}{} + pids = append(pids, current) + root := "/proc/" + strconv.Itoa(current) + children, err := os.ReadFile(filepath.Join(root, "task", strconv.Itoa(current), "children")) + if err != nil { + return err + } + for field := range strings.FieldsSeq(string(children)) { + child, err := strconv.Atoi(field) + if err != nil { + return fmt.Errorf("parse Codex child PID: %w", err) + } + if err := visit(child); err != nil { + return err + } + } + return nil + } + err := visit(pid) + return pids, err +} + +func codexProcessNetworkNamespaces(pid int) ([]string, error) { + seen := map[int]struct{}{} + var namespaces []string + var visit func(int) error + visit = func(current int) error { + if _, ok := seen[current]; ok { + return nil + } + seen[current] = struct{}{} + root := "/proc/" + strconv.Itoa(current) + namespace, err := os.Readlink(filepath.Join(root, "ns/net")) + if err != nil { + return err + } + namespaces = append(namespaces, namespace) + children, err := os.ReadFile(filepath.Join(root, "task", strconv.Itoa(current), "children")) + if err != nil { + return err + } + for field := range strings.FieldsSeq(string(children)) { + child, err := strconv.Atoi(field) + if err != nil { + return fmt.Errorf("parse Codex child PID: %w", err) + } + if err := visit(child); err != nil { + return err + } + } + return nil + } + err := visit(pid) + return namespaces, err +} + func compileCodexNativeFixture(t *testing.T, compiler string, args ...string) { t.Helper() output, err := exec.CommandContext(t.Context(), compiler, args...).CombinedOutput() require.NoError(t, err, string(output)) } + +// TestCodexPinnedStaticPIEArtifact exercises the native release candidate, +// whose ELF type is ET_DYN even though it has no interpreter or libraries. +func TestCodexPinnedStaticPIEArtifact(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + path := os.Getenv("MSGVAULT_CODEX_PINNED_EXECUTABLE") + if path == "" { + t.Skip("set MSGVAULT_CODEX_PINNED_EXECUTABLE to the pinned native artifact") + } + digest, err := hashCodexExecutable(path) + requireChecks.NoError(err) + assertChecks.Equal("78a11f06e0a2dda42d13fba1d50dc62e8cbdb2d5f69789722f4d4d99b5cdbe30", digest) + requireChecks.NoError(validateCodexLaunchArtifact(path, CodexLaunchArtifactNativeStandaloneV1)) + version, err := codexExecutableVersion(t.Context(), CodexExecutable{verifiedPath: path}) + requireChecks.NoError(err) + assertChecks.Equal("codex-cli 0.156.0", version) +} + +func TestCodexBridgeFailsClosedWithoutLinkedOrMatchingDigest(t *testing.T) { + requireChecks := require.New(t) + workRoot := t.TempDir() + requireChecks.NoError(os.Chmod(workRoot, 0o700)) + socketPath := filepath.Join(workRoot, codexProxySocketName) + listener, err := net.Listen("unix", socketPath) + requireChecks.NoError(err) + defer func() { require.NoError(t, listener.Close()) }() + requireChecks.NoError(os.Chmod(socketPath, 0o600)) + bridgePath := filepath.Join(t.TempDir(), "bridge") + requireChecks.NoError(os.WriteFile(bridgePath, []byte("SYNTHETIC_BAD_BRIDGE"), 0o700)) + for _, tc := range []struct { + name string + starter bubblewrapCodexStarter + want string + wantErr error + }{ + {name: "unlinked", starter: bubblewrapCodexStarter{bridgePath: bridgePath}, want: "digest is not linked"}, + {name: "mismatch", starter: bubblewrapCodexStarter{bridgePath: bridgePath, bridgeDigest: strings.Repeat("0", 64)}, want: "digest does not match"}, + {name: "missing", starter: bubblewrapCodexStarter{bridgePath: filepath.Join(workRoot, "missing-bridge"), bridgeDigest: strings.Repeat("0", 64)}, wantErr: os.ErrNotExist}, + } { + t.Run(tc.name, func(t *testing.T) { + process, err := tc.starter.StartWithProxy(t.Context(), CodexExecutable{verifiedPath: "/bin/true"}, codexAppServerArgs, nil, workRoot, socketPath) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + } else { + require.ErrorContains(t, err, tc.want) + } + assert.Nil(t, process) + }) + } +} diff --git a/internal/peoplesweep/codex_isolation_release.go b/internal/peoplesweep/codex_isolation_release.go index 54f778047..4fd75733b 100644 --- a/internal/peoplesweep/codex_isolation_release.go +++ b/internal/peoplesweep/codex_isolation_release.go @@ -1,7 +1,23 @@ package peoplesweep -// releasedCodexAttestations is intentionally empty. Codex App Server v2 -// exposes runtimeWorkspaceRoots, selectedCapabilityRoots, and a read-only -// sandbox but no enforceable readable-root allowlist, so no inspected -// executable digest can prove the packet-only containment boundary. +import "runtime" + +// releasedCodexAttestations stays empty until the pinned artifact passes +// negative read, write, and egress probes and a real packet-only structured +// inference through the production launcher on compatible Linux. var releasedCodexAttestations = map[CodexReleaseKey]CodexAttestation{} + +// CodexReleaseAvailable reports whether this platform has a certified inference +// release. Enrollment must remain unavailable until users can use its credentials. +// Each launch still verifies the selected executable against the release registry. +func CodexReleaseAvailable() bool { + if runtime.GOOS != "linux" { + return false + } + for key, attestation := range releasedCodexAttestations { + if key.ExecutionBoundary == CodexExecutionBoundaryV1 && validReleasedCodexAttestation(key, attestation) { + return true + } + } + return false +} diff --git a/internal/peoplesweep/codex_isolation_test.go b/internal/peoplesweep/codex_isolation_test.go index 57e1c5157..07530b536 100644 --- a/internal/peoplesweep/codex_isolation_test.go +++ b/internal/peoplesweep/codex_isolation_test.go @@ -25,6 +25,15 @@ type isolationCountingStarter struct { starts atomic.Int64 } +type syntheticCodexProxy struct { + attaches atomic.Int64 +} + +func (p *syntheticCodexProxy) Attach(context.Context, string) (CodexProxySession, error) { + p.attaches.Add(1) + return nil, ErrCodexProxyUnreleased +} + func (s *isolationCountingStarter) Start( context.Context, CodexExecutable, @@ -276,7 +285,7 @@ func TestCodexStartExecutesReverifiedSnapshotAfterSourceSwap(t *testing.T) { "#!/bin/sh\nprintf swapped > '"+swappedMarker+"'\n", ), 0o700)) - process, err := NewCodexCommandStarter().Start( + process, err := execCommandStarter{}.Start( t.Context(), attestation.VerifiedExecutable(), []string{"app-server"}, scrubCodexEnvironment(os.Environ()), t.TempDir(), ) @@ -348,7 +357,7 @@ func TestCodexAppServerCleanupTerminatesDescendantProcess(t *testing.T) { executable, _ := buildCodexIsolationExecutableFixture(t, codexIsolationExecutableFixture{ version: codexIsolationFixtureVersion, mode: "app-server-descendant", marker: lateMarker, }) - process, err := NewCodexCommandStarter().Start( + process, err := execCommandStarter{}.Start( t.Context(), CodexExecutable{sourcePath: executable, verifiedPath: executable}, []string{"app-server"}, scrubCodexEnvironment(os.Environ()), t.TempDir(), ) @@ -392,19 +401,75 @@ func TestCodexReleasedFixtureRequiresExactVersion(t *testing.T) { // TestCodexFactoryFailsBeforeStartingProcess catches construction of an // unreleased Codex transport reaching the App Server process boundary. func TestCodexFactoryFailsBeforeStartingProcess(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) executable, _ := buildCodexIsolationExecutableFixture(t, codexIsolationExecutableFixture{ version: codexIsolationFixtureVersion, }) starter := &isolationCountingStarter{} registry, err := NewDriverRegistry(nil, starter, NewReleasedCodexIsolationGate()) - require.NoError(err) + requireChecks.NoError(err) transport, err := registry.Driver(ProtocolCodexAppServer, codexIsolationTransportConfig(executable)) - require.ErrorIs(err, ErrCodexIsolationUnreleased) - assert.Nil(transport) - assert.Zero(starter.starts.Load()) + requireChecks.ErrorIs(err, ErrCodexIsolationUnreleased) + assertChecks.Nil(transport) + assertChecks.Zero(starter.starts.Load()) +} + +func TestCodexProxySeamDeniesBeforeProcessStart(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + executable, contents := buildCodexIsolationExecutableFixture(t, codexIsolationExecutableFixture{ + version: codexIsolationFixtureVersion, + }) + starter := &isolationCountingStarter{} + proxy := &syntheticCodexProxy{} + launcher := codexBoundLauncher{ + gate: injectedReleasedCodexGate{registry: codexIsolationFixtureRegistry(contents)}, + starter: starter, proxy: proxy, + } + attestation, err := launcher.Verify(t.Context(), executable) + requireChecks.NoError(err) + defer func() { require.NoError(t, attestation.Close()) }() + process, err := launcher.Start(t.Context(), attestation, "") + requireChecks.ErrorIs(err, ErrCodexProxyUnreleased) + assertChecks.Nil(process) + assertChecks.Zero(starter.starts.Load()) + assertChecks.Zero(proxy.attaches.Load()) +} + +func TestCodexLauncherRejectsUntrustedAuthHome(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + if runtime.GOOS == "windows" { + t.Skip("codex auth home permission gates require Unix permission bits") + } + executable, contents := buildCodexIsolationExecutableFixture(t, codexIsolationExecutableFixture{ + version: codexIsolationFixtureVersion, + }) + starter := &isolationCountingStarter{} + launcher := codexBoundLauncher{ + gate: injectedReleasedCodexGate{registry: codexIsolationFixtureRegistry(contents)}, starter: starter, + } + attestation, err := launcher.Verify(t.Context(), executable) + requireChecks.NoError(err) + defer func() { require.NoError(t, attestation.Close()) }() + privateHome := t.TempDir() + requireChecks.NoError(os.Chmod(privateHome, 0o700)) + requireChecks.NoError(os.WriteFile(filepath.Join(privateHome, "auth.json"), []byte("SYNTHETIC"), 0o644)) + _, err = launcher.Start(t.Context(), attestation, privateHome) + requireChecks.ErrorContains(err, "private regular file") + assertChecks.Zero(starter.starts.Load()) + linkHome := filepath.Join(t.TempDir(), "linked-auth") + requireChecks.NoError(os.Symlink(privateHome, linkHome)) + _, err = launcher.Start(t.Context(), attestation, linkHome) + requireChecks.ErrorContains(err, "private directory") + assertChecks.Zero(starter.starts.Load()) + missingHome := filepath.Join(t.TempDir(), "SYNTHETIC_PRIVATE_AUTH_PATH") + _, err = launcher.Start(t.Context(), attestation, missingHome) + requireChecks.ErrorContains(err, "auth home is unavailable") + assertChecks.NotContains(err.Error(), "SYNTHETIC_PRIVATE_AUTH_PATH") + assertChecks.Zero(starter.starts.Load()) } // TestCodexReverifyRejectsExecutableReplacement catches an executable being diff --git a/internal/peoplesweep/codex_process.go b/internal/peoplesweep/codex_process.go new file mode 100644 index 000000000..c59a34f1f --- /dev/null +++ b/internal/peoplesweep/codex_process.go @@ -0,0 +1,227 @@ +package peoplesweep + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "sync" +) + +type codexBoundLauncher struct { + gate CodexIsolationGate + starter CommandStarter + proxy CodexServiceProxy +} + +// CodexServiceProxy attaches a bounded host-side service to a disposable work +// root. The default is nil, which keeps the child's network namespace disconnected. +type CodexServiceProxy interface { + Attach(ctx context.Context, workRoot string) (CodexProxySession, error) +} + +type CodexProxySession interface { + SocketPath() string + Close() error +} + +type codexProxyCommandStarter interface { + StartWithProxy(ctx context.Context, executable CodexExecutable, args []string, env []string, dir string, socketPath string) (RPCProcess, error) +} + +var ErrCodexProxyUnreleased = errors.New("codex service proxy is not released") + +type codexOwnedProcess struct { + RPCProcess + + workRoot string + cleanupMu sync.Mutex + cleaned bool + removeRoot func(string) error + proxy CodexProxySession + cleanupErr error + refreshCommit func() error + childExited chan struct{} + childExitOnce sync.Once +} + +func (p *codexOwnedProcess) Wait() error { + processErr := p.RPCProcess.Wait() + if p.childExited != nil { + p.childExitOnce.Do(func() { close(p.childExited) }) + } + var refreshErr error + p.cleanupMu.Lock() + // Authentication refresh is independent of the operation's result. Copy + // back only after the child has exited, including a nonzero or killed exit. + if !p.cleaned && p.refreshCommit != nil { + refreshErr = p.refreshCommit() + } + p.cleanupMu.Unlock() + return errors.Join(processErr, refreshErr, p.cleanup()) +} + +func (p *codexOwnedProcess) cleanup() error { + p.cleanupMu.Lock() + defer p.cleanupMu.Unlock() + if p.cleaned { + return p.cleanupErr + } + remove := p.removeRoot + if remove == nil { + remove = os.RemoveAll + } + var proxyErr error + if p.proxy != nil { + proxyErr = p.proxy.Close() + } + if err := remove(p.workRoot); err != nil { + return errors.Join(proxyErr, err) + } + p.cleaned = true + p.cleanupErr = proxyErr + return p.cleanupErr +} + +func (l codexBoundLauncher) Verify(ctx context.Context, executable string) (CodexAttestation, error) { + return l.gate.Verify(ctx, executable, CodexExecutionBoundaryV1) +} + +func (l codexBoundLauncher) Start( + ctx context.Context, + attestation CodexAttestation, + authHome string, +) (RPCProcess, error) { + return l.start(ctx, attestation, authHome, false) +} + +func (l codexBoundLauncher) start( + ctx context.Context, + attestation CodexAttestation, + authHome string, + loginOnly bool, +) (RPCProcess, error) { + if err := l.gate.ReverifyForLaunch(attestation); err != nil { + return nil, fmt.Errorf("reverify codex app-server isolation: %w", err) + } + var proxyStarter codexProxyCommandStarter + if l.proxy != nil { + var ok bool + proxyStarter, ok = l.starter.(codexProxyCommandStarter) + if !ok { + return nil, ErrCodexProxyUnreleased + } + } + workRoot, err := os.MkdirTemp("", "msgvault-codex-work-") + if err != nil { + return nil, fmt.Errorf("create codex work root: %w", err) + } + cleanup := func(err error) error { + return errors.Join(err, os.RemoveAll(workRoot)) + } + var refresh *codexRefreshState + if loginOnly { + if err := os.Mkdir(filepath.Join(workRoot, ".codex"), 0o700); err != nil { + return nil, cleanup(fmt.Errorf("create codex login state: %w", err)) + } + } else if authHome != "" { + if err := stageCodexAuth(authHome, workRoot); err != nil { + return nil, cleanup(fmt.Errorf("stage codex auth: %w", err)) + } + refresh, err = prepareCodexRefresh(authHome, workRoot) + if err != nil { + return nil, cleanup(fmt.Errorf("snapshot codex auth: %w", err)) + } + } + var proxySession CodexProxySession + if l.proxy != nil { + proxySession, err = l.proxy.Attach(ctx, workRoot) + if err != nil { + return nil, cleanup(fmt.Errorf("attach codex service proxy: %w", err)) + } + if proxySession == nil { + return nil, cleanup(errors.New("codex service proxy did not create a session")) + } + } + cleanupStart := func(err error) error { + if proxySession != nil { + err = errors.Join(err, proxySession.Close()) + } + return cleanup(err) + } + var process RPCProcess + if proxySession == nil { + process, err = l.starter.Start(ctx, attestation.VerifiedExecutable(), slices.Clone(codexAppServerArgs), nil, workRoot) + } else { + process, err = proxyStarter.StartWithProxy(ctx, attestation.VerifiedExecutable(), slices.Clone(codexAppServerArgs), nil, workRoot, proxySession.SocketPath()) + } + if err != nil { + return nil, cleanupStart(err) + } + owned := &codexOwnedProcess{RPCProcess: process, workRoot: workRoot, proxy: proxySession, childExited: make(chan struct{})} + if refresh != nil { + owned.refreshCommit = refresh.commit + } + return owned, nil +} + +func stageCodexAuth(authHome, workRoot string) error { + if !filepath.IsAbs(authHome) || filepath.Clean(authHome) != authHome { + return errors.New("codex auth home must be an absolute clean path") + } + info, err := os.Lstat(authHome) + if err != nil { + return errors.New("codex auth home is unavailable") + } + if !info.IsDir() || info.Mode().Perm()&0o077 != 0 || !codexAuthOwnedByDaemon(info) { + return errors.New("codex auth home must be a private directory") + } + root, err := os.OpenRoot(authHome) + if err != nil { + return errors.New("codex auth home cannot be opened") + } + defer func() { _ = root.Close() }() + info, err = root.Lstat("auth.json") + if err != nil { + return errors.New("codex auth.json is unavailable") + } + if !info.Mode().IsRegular() || info.Mode().Perm()&0o077 != 0 || + info.Size() > 1<<20 || !codexAuthOwnedByDaemon(info) { + return errors.New("codex auth.json must be a private regular file under 1 MiB") + } + source, err := root.Open("auth.json") + if err != nil { + return errors.New("codex auth.json cannot be opened") + } + defer func() { _ = source.Close() }() + opened, err := source.Stat() + if err != nil { + return errors.New("codex auth.json cannot be inspected") + } + if !os.SameFile(info, opened) || !codexAuthOwnedByDaemon(opened) { + return errors.New("codex auth.json changed during open") + } + destDir := filepath.Join(workRoot, ".codex") + if err := os.Mkdir(destDir, 0o700); err != nil { + return err + } + dest, err := os.OpenFile(filepath.Join(destDir, "auth.json"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + _, copyErr := io.CopyN(dest, source, 1<<20+1) + closeErr := dest.Close() + if copyErr != nil && !errors.Is(copyErr, io.EOF) { + return errors.New("codex auth.json cannot be staged") + } + if closeErr != nil { + return errors.New("codex staged auth.json cannot be closed") + } + if copied, err := os.Stat(filepath.Join(destDir, "auth.json")); err != nil || copied.Size() > 1<<20 { + return errors.New("codex auth.json exceeds 1 MiB") + } + return nil +} diff --git a/internal/peoplesweep/codex_process_linux.go b/internal/peoplesweep/codex_process_linux.go new file mode 100644 index 000000000..ca410cf33 --- /dev/null +++ b/internal/peoplesweep/codex_process_linux.go @@ -0,0 +1,185 @@ +//go:build linux + +package peoplesweep + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "syscall" +) + +// bubblewrapCodexStarter confines the pinned, standalone Codex binary to a +// disposable work root and an empty network namespace. The bound launcher +// stages only an explicitly supplied auth.json in that root. +type bubblewrapCodexStarter struct { + bridgePath string + bridgeDigest string +} + +// codexBridgeSHA256 is linked into Linux daemon builds from the adjacent +// static bridge artifact. An unlinked development binary fails closed. +var codexBridgeSHA256 string + +func codexAuthOwnedByDaemon(info os.FileInfo) bool { + stat, ok := info.Sys().(*syscall.Stat_t) + uid := os.Geteuid() + return ok && uid >= 0 && uint64(stat.Uid) == uint64(uid) +} + +func NewCodexCommandStarter() CommandStarter { + return bubblewrapCodexStarter{bridgeDigest: codexBridgeSHA256} +} + +// Tests supply the built helper and its digest through the same launcher path. +func newCodexCommandStarterWithBridge(path, digest string) CommandStarter { + return bubblewrapCodexStarter{bridgePath: path, bridgeDigest: digest} +} + +func (s bubblewrapCodexStarter) Start( + ctx context.Context, + executable CodexExecutable, + args []string, + env []string, + dir string, +) (RPCProcess, error) { + return s.start(ctx, executable, args, env, dir, "") +} + +func (s bubblewrapCodexStarter) StartWithProxy( + ctx context.Context, executable CodexExecutable, args, env []string, dir, socketPath string, +) (RPCProcess, error) { + return s.start(ctx, executable, args, env, dir, socketPath) +} + +func (s bubblewrapCodexStarter) start( + ctx context.Context, executable CodexExecutable, args, env []string, dir, socketPath string, +) (RPCProcess, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if !slices.Equal(args, codexAppServerArgs) { + return nil, errors.New("codex launcher only accepts the reviewed app-server command") + } + if len(env) != 0 { + return nil, errors.New("codex launcher does not accept an inherited environment") + } + if executable.verifiedPath == "" || !filepath.IsAbs(executable.verifiedPath) || + !filepath.IsAbs(dir) || filepath.Clean(dir) != dir { + return nil, errors.New("codex launcher requires absolute verified executable and work root") + } + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + return nil, errors.New("codex launcher work root is unavailable") + } + var bridge *verifiedCodexExecutable + if socketPath != "" { + if len(s.bridgeDigest) != 64 { + return nil, errors.New("codex proxy bridge digest is not linked into the daemon") + } + if socketPath != filepath.Join(dir, codexProxySocketName) { + return nil, errors.New("codex proxy socket must be inside the work root") + } + info, err := os.Lstat(socketPath) + if err != nil || info.Mode()&os.ModeSocket == 0 || info.Mode().Perm()&0o077 != 0 || !codexAuthOwnedByDaemon(info) { + return nil, errors.New("codex proxy socket is unavailable") + } + path := s.bridgePath + if path == "" { + self, err := os.Executable() + if err != nil { + return nil, errors.New("locate codex proxy bridge") + } + path = filepath.Join(filepath.Dir(self), "msgvault-codex-bridge") + } + info, err = os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("codex proxy bridge is missing: %w", os.ErrNotExist) + } + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0o022 != 0 || !codexAuthOwnedByDaemon(info) { + return nil, errors.New("codex proxy bridge must be daemon-owned and not group writable") + } + var actualDigest string + bridge, actualDigest, err = snapshotCodexExecutable(path) + if err != nil { + return nil, errors.New("snapshot codex proxy bridge") + } + if actualDigest != s.bridgeDigest { + _ = bridge.Close() + return nil, errors.New("codex proxy bridge digest does not match daemon build") + } + if err := validateCodexLaunchArtifact(bridge.path, CodexLaunchArtifactNativeStandaloneV1); err != nil { + _ = bridge.Close() + return nil, errors.New("codex proxy bridge must be a static native executable") + } + } + keepBridge := false + defer func() { + if bridge != nil && !keepBridge { + _ = bridge.Close() + } + }() + const bubblewrap = "/usr/bin/bwrap" + if info, err := os.Stat(bubblewrap); err != nil || !info.Mode().IsRegular() || info.Mode()&0o111 == 0 { + return nil, errors.New("codex launcher requires Bubblewrap") + } + const caBundle = "/etc/ssl/certs/ca-certificates.crt" + caInfo, err := os.Stat(caBundle) + if err != nil || !caInfo.Mode().IsRegular() || caInfo.Size() == 0 || + caInfo.Mode().Perm()&0o022 != 0 { + return nil, errors.New("codex launcher requires a readable system CA bundle") + } + caStat, ok := caInfo.Sys().(*syscall.Stat_t) + if !ok || caStat.Uid != 0 { + return nil, errors.New("codex launcher requires a root-owned system CA bundle") + } + bwrapArgs := []string{ + "--unshare-user", "--unshare-pid", "--unshare-net", "--die-with-parent", "--new-session", + "--clearenv", "--tmpfs", "/tmp", "--dir", "/work", "--bind", dir, "/work", + "--dir", "/work/.codex", + "--dir", "/etc", "--dir", "/etc/ssl", "--dir", "/etc/ssl/certs", + "--ro-bind", caBundle, caBundle, + "--ro-bind", executable.verifiedPath, "/codex", "--proc", "/proc", "--dev", "/dev", + "--setenv", "HOME", "/work", "--setenv", "CODEX_HOME", "/work/.codex", + "--setenv", "SSL_CERT_FILE", caBundle, + "--setenv", "TMPDIR", "/tmp", "--chdir", "/work", + } + entrypoint := "/codex" + if bridge != nil { + bwrapArgs = append(bwrapArgs, "--ro-bind", bridge.path, "/bridge", + "--setenv", "HTTPS_PROXY", "http://127.0.0.1:3128", + "--setenv", "https_proxy", "http://127.0.0.1:3128", + "--setenv", "ALL_PROXY", "http://127.0.0.1:3128", + "--setenv", "all_proxy", "http://127.0.0.1:3128") + entrypoint = "/bridge" + } + bwrapArgs = append(bwrapArgs, "--", entrypoint) + bwrapArgs = append(bwrapArgs, args...) + process, err := execCommandStarter{}.Start( + ctx, CodexExecutable{verifiedPath: bubblewrap}, bwrapArgs, []string{"PATH=/usr/bin:/bin"}, dir, + ) + if err != nil { + return nil, err + } + if bridge != nil { + keepBridge = true + return &codexBridgeOwnedProcess{RPCProcess: process, bridge: bridge}, nil + } + return process, nil +} + +type codexBridgeOwnedProcess struct { + RPCProcess + + bridge *verifiedCodexExecutable +} + +func (p *codexBridgeOwnedProcess) Wait() error { + return errors.Join(p.RPCProcess.Wait(), p.bridge.Close()) +} + +func (p *codexBridgeOwnedProcess) Kill() error { + return errors.Join(p.RPCProcess.Kill(), p.bridge.Close()) +} diff --git a/internal/peoplesweep/codex_process_unsupported.go b/internal/peoplesweep/codex_process_unsupported.go new file mode 100644 index 000000000..c6f17334d --- /dev/null +++ b/internal/peoplesweep/codex_process_unsupported.go @@ -0,0 +1,20 @@ +//go:build !linux + +package peoplesweep + +import ( + "context" + "errors" + "os" +) + +type unavailableCodexStarter struct{} + +// No credential-bearing Codex process can launch on this platform. +func codexAuthOwnedByDaemon(os.FileInfo) bool { return true } + +func NewCodexCommandStarter() CommandStarter { return unavailableCodexStarter{} } + +func (unavailableCodexStarter) Start(context.Context, CodexExecutable, []string, []string, string) (RPCProcess, error) { + return nil, errors.New("codex containment launcher is unavailable on this platform") +} diff --git a/internal/peoplesweep/codex_proxy_linux.go b/internal/peoplesweep/codex_proxy_linux.go new file mode 100644 index 000000000..1aa09d5f1 --- /dev/null +++ b/internal/peoplesweep/codex_proxy_linux.go @@ -0,0 +1,221 @@ +//go:build linux + +package peoplesweep + +import ( + "bufio" + "context" + "errors" + "io" + "net" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const codexProxySocketName = ".proxy.sock" + +func defaultCodexServiceProxy() CodexServiceProxy { return newCodexHostServiceProxy(nil) } + +// Pinned 0.156.0 uses the ChatGPT backend for subscription inference and the +// OpenAI OAuth issuer for login and refresh. Keep this list exact. +var codexProxyAuthorities = map[string]struct{}{ + "chatgpt.com:443": {}, + "auth.openai.com:443": {}, +} + +type codexHostServiceProxy struct { + dial func(context.Context, string, string) (net.Conn, error) +} + +func newCodexHostServiceProxy(dial func(context.Context, string, string) (net.Conn, error)) CodexServiceProxy { + if dial == nil { + dial = (&net.Dialer{Timeout: 5 * time.Second}).DialContext + } + return codexHostServiceProxy{dial: dial} +} + +func (p codexHostServiceProxy) Attach(ctx context.Context, workRoot string) (CodexProxySession, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if !filepath.IsAbs(workRoot) || filepath.Clean(workRoot) != workRoot { + return nil, errors.New("codex proxy requires an absolute work root") + } + info, err := os.Lstat(workRoot) + if err != nil || !info.IsDir() || info.Mode().Perm()&0o077 != 0 || !codexAuthOwnedByDaemon(info) { + return nil, errors.New("codex proxy requires a private daemon-owned work root") + } + socketPath := filepath.Join(workRoot, codexProxySocketName) + if _, err := os.Lstat(socketPath); err == nil || !errors.Is(err, os.ErrNotExist) { + return nil, errors.New("codex proxy socket path is occupied") + } + listener, err := net.Listen("unix", socketPath) + if err != nil { + return nil, errors.New("create codex service proxy socket") + } + if err := os.Chmod(socketPath, 0o600); err != nil { + _ = listener.Close() + return nil, errors.New("secure codex service proxy socket") + } + proxyCtx, cancel := context.WithCancel(ctx) + session := &codexHostProxySession{ + path: socketPath, listener: listener, cancel: cancel, + dial: p.dial, active: make(map[net.Conn]struct{}), + } + session.wg.Add(1) + go session.accept(proxyCtx) + go func() { + <-proxyCtx.Done() + _ = session.Close() + }() + return session, nil +} + +type codexHostProxySession struct { + path string + listener net.Listener + cancel context.CancelFunc + dial func(context.Context, string, string) (net.Conn, error) + mu sync.Mutex + active map[net.Conn]struct{} + closed bool + wg sync.WaitGroup + once sync.Once + closeErr error +} + +func (s *codexHostProxySession) SocketPath() string { return s.path } + +func (s *codexHostProxySession) Close() error { + s.once.Do(func() { + s.cancel() + s.mu.Lock() + s.closed = true + s.closeErr = s.listener.Close() + for conn := range s.active { + _ = conn.Close() + } + s.mu.Unlock() + s.wg.Wait() + }) + return s.closeErr +} + +func (s *codexHostProxySession) accept(ctx context.Context) { + defer s.wg.Done() + for { + conn, err := s.listener.Accept() + if err != nil { + return + } + if !s.track(conn) { + _ = conn.Close() + return + } + s.wg.Add(1) + go s.serve(ctx, conn) + } +} + +func (s *codexHostProxySession) track(conn net.Conn) bool { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return false + } + s.active[conn] = struct{}{} + return true +} + +func (s *codexHostProxySession) untrack(conn net.Conn) { + s.mu.Lock() + delete(s.active, conn) + s.mu.Unlock() + _ = conn.Close() +} + +func (s *codexHostProxySession) serve(ctx context.Context, client net.Conn) { + defer s.wg.Done() + defer s.untrack(client) + _ = client.SetReadDeadline(time.Now().Add(5 * time.Second)) + reader := bufio.NewReaderSize(client, 4096) + authority, ok := readCodexConnect(reader) + if !ok { + _, _ = io.WriteString(client, "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n") + return + } + _ = client.SetReadDeadline(time.Time{}) + dialCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + upstream, err := s.dial(dialCtx, "tcp", authority) + cancel() + if err != nil { + _, _ = io.WriteString(client, "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n") + return + } + if !s.track(upstream) { + _ = upstream.Close() + return + } + defer s.untrack(upstream) + if _, err := io.WriteString(client, "HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil { + return + } + done := make(chan struct{}) + go func() { + _, _ = io.Copy(upstream, reader) + close(done) + }() + _, _ = io.Copy(client, upstream) + _ = client.Close() + _ = upstream.Close() + <-done +} + +func readCodexConnect(reader *bufio.Reader) (string, bool) { + const maxHeaders = 8192 + total := 0 + line, ok := readCodexProxyLine(reader, &total, maxHeaders) + if !ok { + return "", false + } + parts := strings.Split(strings.TrimSuffix(line, "\r\n"), " ") + if len(parts) != 3 || parts[0] != "CONNECT" || parts[2] != "HTTP/1.1" { + return "", false + } + authority := parts[1] + if _, allowed := codexProxyAuthorities[authority]; !allowed { + return "", false + } + hostSeen := false + for { + line, ok = readCodexProxyLine(reader, &total, maxHeaders) + if !ok { + return "", false + } + if line == "\r\n" { + return authority, hostSeen + } + name, value, found := strings.Cut(strings.TrimSuffix(line, "\r\n"), ":") + if !found || strings.TrimSpace(name) == "" { + return "", false + } + if strings.EqualFold(name, "Host") { + if hostSeen || strings.TrimSpace(value) != authority { + return "", false + } + hostSeen = true + } + } +} + +func readCodexProxyLine(reader *bufio.Reader, total *int, maximum int) (string, bool) { + line, err := reader.ReadSlice('\n') + *total += len(line) + if err != nil || *total > maximum || len(line) < 2 || line[len(line)-2] != '\r' { + return "", false + } + return string(line), true +} diff --git a/internal/peoplesweep/codex_proxy_linux_test.go b/internal/peoplesweep/codex_proxy_linux_test.go new file mode 100644 index 000000000..cddf4283c --- /dev/null +++ b/internal/peoplesweep/codex_proxy_linux_test.go @@ -0,0 +1,95 @@ +//go:build linux + +package peoplesweep + +import ( + "bufio" + "context" + "io" + "net" + "os" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCodexServiceProxyAllowsOnlyPinnedConnectAuthorities(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + workRoot := t.TempDir() + requireChecks.NoError(os.Chmod(workRoot, 0o700)) + upstream, err := net.Listen("tcp", "127.0.0.1:0") + requireChecks.NoError(err) + t.Cleanup(func() { require.NoError(t, upstream.Close()) }) + var dials atomic.Int64 + proxy := newCodexHostServiceProxy(func(ctx context.Context, network, address string) (net.Conn, error) { + dials.Add(1) + assert.Equal(t, "tcp", network) + assert.Contains(t, codexProxyAuthorities, address) + return (&net.Dialer{}).DialContext(ctx, "tcp", upstream.Addr().String()) + }) + session, err := proxy.Attach(t.Context(), workRoot) + requireChecks.NoError(err) + t.Cleanup(func() { require.NoError(t, session.Close()) }) + + for _, request := range []string{ + "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n", + "CONNECT 127.0.0.1:443 HTTP/1.1\r\nHost: 127.0.0.1:443\r\n\r\n", + "CONNECT chatgpt.com:444 HTTP/1.1\r\nHost: chatgpt.com:444\r\n\r\n", + "CONNECT chatgpt.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n", + "GET http://chatgpt.com/ HTTP/1.1\r\nHost: chatgpt.com\r\n\r\n", + } { + conn, err := net.Dial("unix", session.SocketPath()) + requireChecks.NoError(err) + requireChecks.NoError(conn.SetDeadline(time.Now().Add(2 * time.Second))) + _, err = io.WriteString(conn, request) + requireChecks.NoError(err) + line, err := bufio.NewReader(conn).ReadString('\n') + requireChecks.NoError(err) + assertChecks.True(strings.HasPrefix(line, "HTTP/1.1 403"), line) + requireChecks.NoError(conn.Close()) + } + assertChecks.Zero(dials.Load()) + + for index, authority := range []string{"chatgpt.com:443", "auth.openai.com:443"} { + conn, err := net.Dial("unix", session.SocketPath()) + requireChecks.NoError(err) + requireChecks.NoError(conn.SetDeadline(time.Now().Add(2 * time.Second))) + _, err = io.WriteString(conn, "CONNECT "+authority+" HTTP/1.1\r\nHost: "+authority+"\r\n\r\n") + requireChecks.NoError(err) + tcpUpstream, ok := upstream.(*net.TCPListener) + requireChecks.True(ok) + requireChecks.NoError(tcpUpstream.SetDeadline(time.Now().Add(2 * time.Second))) + upstreamConn, err := upstream.Accept() + requireChecks.NoError(err) + requireChecks.NoError(upstreamConn.SetDeadline(time.Now().Add(2 * time.Second))) + reader := bufio.NewReader(conn) + line, err := reader.ReadString('\n') + requireChecks.NoError(err) + assertChecks.Equal("HTTP/1.1 200 Connection Established\r\n", line) + line, err = reader.ReadString('\n') + requireChecks.NoError(err) + assertChecks.Equal("\r\n", line) + _, err = io.WriteString(conn, "synthetic request") + requireChecks.NoError(err) + request := make([]byte, len("synthetic request")) + _, err = io.ReadFull(upstreamConn, request) + requireChecks.NoError(err) + assertChecks.Equal("synthetic request", string(request)) + _, err = io.WriteString(upstreamConn, "synthetic response") + requireChecks.NoError(err) + response := make([]byte, len("synthetic response")) + _, err = io.ReadFull(reader, response) + requireChecks.NoError(err) + assertChecks.Equal("synthetic response", string(response)) + assertChecks.Equal(int64(index+1), dials.Load()) + requireChecks.NoError(conn.Close()) + requireChecks.NoError(upstreamConn.Close()) + } + requireChecks.NoError(session.Close()) + assertChecks.NoFileExists(session.SocketPath()) +} diff --git a/internal/peoplesweep/codex_proxy_unsupported.go b/internal/peoplesweep/codex_proxy_unsupported.go new file mode 100644 index 000000000..7ad6924d3 --- /dev/null +++ b/internal/peoplesweep/codex_proxy_unsupported.go @@ -0,0 +1,16 @@ +//go:build !linux + +package peoplesweep + +import ( + "context" + "errors" +) + +type unavailableCodexProxy struct{} + +func defaultCodexServiceProxy() CodexServiceProxy { return unavailableCodexProxy{} } + +func (unavailableCodexProxy) Attach(context.Context, string) (CodexProxySession, error) { + return nil, errors.New("codex service proxy is unavailable on this platform") +} diff --git a/internal/peoplesweep/codex_refresh.go b/internal/peoplesweep/codex_refresh.go new file mode 100644 index 000000000..8e4381305 --- /dev/null +++ b/internal/peoplesweep/codex_refresh.go @@ -0,0 +1,210 @@ +package peoplesweep + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "errors" + "io" + "os" + "path/filepath" + "strings" + "sync" +) + +var ( + ErrCodexAuthAccountChanged = errors.New("codex account identity changed") + ErrCodexAuthSourceChanged = errors.New("codex dedicated auth changed during inference") + ErrCodexAuthRefreshUnsafe = errors.New("codex refreshed auth is unsafe") +) + +// A single daemon can hold one auth context across login, model discovery, and +// inference. The source-file snapshot below also detects uncoordinated writes. +var codexAuthOperationLocks sync.Map + +func lockCodexAuthOperation(ctx context.Context, authHome string) (func(), error) { + if authHome == "" { + return func() {}, nil + } + created := make(chan struct{}, 1) + created <- struct{}{} + value, _ := codexAuthOperationLocks.LoadOrStore(authHome, created) + gate, ok := value.(chan struct{}) + if !ok { + return nil, ErrCodexAuthRefreshUnsafe + } + select { + case <-gate: + return func() { gate <- struct{}{} }, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +type codexAccountIdentity struct { + userID string + workspaceID string +} + +func codexAccountIdentityFromAuth(contents []byte) (codexAccountIdentity, error) { + var auth struct { + Mode string `json:"auth_mode"` + APIKey *string `json:"OPENAI_API_KEY"` + Tokens *struct { + IDToken string `json:"id_token"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + AccountID string `json:"account_id"` + } `json:"tokens"` + } + if decodeSingleJSON(contents, &auth) != nil || auth.Mode != "chatgpt" || auth.APIKey != nil || auth.Tokens == nil || + auth.Tokens.AccessToken == "" || auth.Tokens.RefreshToken == "" || !safeProviderMetadata(auth.Tokens.AccountID) { + return codexAccountIdentity{}, ErrCodexAuthRefreshUnsafe + } + parts := strings.Split(auth.Tokens.IDToken, ".") + if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { + return codexAccountIdentity{}, ErrCodexAuthRefreshUnsafe + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil || len(payload) > 16<<10 { + return codexAccountIdentity{}, ErrCodexAuthRefreshUnsafe + } + var claims struct { + Auth struct { + UserID string `json:"chatgpt_user_id"` + WorkspaceID string `json:"chatgpt_account_id"` + } `json:"https://api.openai.com/auth"` + } + if decodeSingleJSON(payload, &claims) != nil || !safeProviderMetadata(claims.Auth.UserID) || + !safeProviderMetadata(claims.Auth.WorkspaceID) || claims.Auth.WorkspaceID != auth.Tokens.AccountID { + return codexAccountIdentity{}, ErrCodexAuthRefreshUnsafe + } + return codexAccountIdentity{userID: claims.Auth.UserID, workspaceID: claims.Auth.WorkspaceID}, nil +} + +func readPrivateCodexAuth(directory string) ([]byte, os.FileInfo, error) { + dirInfo, err := os.Lstat(directory) //nolint:gosec // The daemon-selected auth directory is checked for private mode and ownership below. + if err != nil || !dirInfo.IsDir() || dirInfo.Mode().Perm()&0o077 != 0 || !codexAuthOwnedByDaemon(dirInfo) { + return nil, nil, ErrCodexAuthRefreshUnsafe + } + root, err := os.OpenRoot(directory) + if err != nil { + return nil, nil, ErrCodexAuthRefreshUnsafe + } + defer func() { _ = root.Close() }() + info, err := root.Lstat("auth.json") + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0o077 != 0 || info.Size() <= 0 || + info.Size() > 1<<20 || !codexAuthOwnedByDaemon(info) { + return nil, nil, ErrCodexAuthRefreshUnsafe + } + file, err := root.Open("auth.json") + if err != nil { + return nil, nil, ErrCodexAuthRefreshUnsafe + } + defer func() { _ = file.Close() }() + opened, err := file.Stat() + if err != nil || !os.SameFile(info, opened) || !codexAuthOwnedByDaemon(opened) { + return nil, nil, ErrCodexAuthRefreshUnsafe + } + contents, err := io.ReadAll(io.LimitReader(file, 1<<20+1)) + if err != nil || len(contents) == 0 || len(contents) > 1<<20 { + return nil, nil, ErrCodexAuthRefreshUnsafe + } + finalInfo, err := file.Stat() + if err != nil || !os.SameFile(opened, finalInfo) || finalInfo.Size() != int64(len(contents)) || + !finalInfo.ModTime().Equal(opened.ModTime()) { + return nil, nil, ErrCodexAuthRefreshUnsafe + } + return contents, opened, nil +} + +type codexRefreshState struct { + authHome string + workRoot string + sourceInfo os.FileInfo + sourceDigest [sha256.Size]byte + identity codexAccountIdentity + identityOK bool +} + +func prepareCodexRefresh(authHome, workRoot string) (*codexRefreshState, error) { + staged, _, err := readPrivateCodexAuth(filepath.Join(workRoot, ".codex")) + if err != nil { + return nil, err + } + source, info, err := readPrivateCodexAuth(authHome) + if err != nil { + return nil, err + } + if sha256.Sum256(staged) != sha256.Sum256(source) { + return nil, ErrCodexAuthSourceChanged + } + identity, identityErr := codexAccountIdentityFromAuth(staged) + return &codexRefreshState{ + authHome: authHome, workRoot: workRoot, sourceInfo: info, + sourceDigest: sha256.Sum256(staged), identity: identity, identityOK: identityErr == nil, + }, nil +} + +func (s *codexRefreshState) checkSourceUnchanged() error { + current, info, err := readPrivateCodexAuth(s.authHome) + if err != nil || !os.SameFile(s.sourceInfo, info) || sha256.Sum256(current) != s.sourceDigest { + return ErrCodexAuthSourceChanged + } + return nil +} + +func (s *codexRefreshState) commit() (retErr error) { + if err := s.checkSourceUnchanged(); err != nil { + return err + } + candidate, _, err := readPrivateCodexAuth(filepath.Join(s.workRoot, ".codex")) + if err != nil { + return err + } + if sha256.Sum256(candidate) == s.sourceDigest { + return nil + } + identity, err := codexAccountIdentityFromAuth(candidate) + if !s.identityOK || err != nil { + return ErrCodexAuthRefreshUnsafe + } + if identity != s.identity { + return ErrCodexAuthAccountChanged + } + temp, err := os.CreateTemp(s.authHome, ".auth-refresh-") + if err != nil { + return ErrCodexAuthRefreshUnsafe + } + defer func() { + if err := os.Remove(temp.Name()); err != nil && !errors.Is(err, os.ErrNotExist) { + retErr = errors.Join(retErr, ErrCodexAuthRefreshUnsafe) + } + }() + if err := temp.Chmod(0o600); err != nil { + _ = temp.Close() + return ErrCodexAuthRefreshUnsafe + } + _, writeErr := temp.Write(candidate) + syncErr := temp.Sync() + closeErr := temp.Close() + if writeErr != nil || syncErr != nil || closeErr != nil { + return ErrCodexAuthRefreshUnsafe + } + if err := s.checkSourceUnchanged(); err != nil { + return err + } + if err := os.Rename(temp.Name(), filepath.Join(s.authHome, "auth.json")); err != nil { + return ErrCodexAuthRefreshUnsafe + } + directory, err := os.Open(s.authHome) + if err != nil { + return ErrCodexAuthRefreshUnsafe + } + syncErr = directory.Sync() + closeErr = directory.Close() + if syncErr != nil || closeErr != nil { + return ErrCodexAuthRefreshUnsafe + } + return nil +} diff --git a/internal/peoplesweep/codex_refresh_linux_test.go b/internal/peoplesweep/codex_refresh_linux_test.go new file mode 100644 index 000000000..e4d07a3ee --- /dev/null +++ b/internal/peoplesweep/codex_refresh_linux_test.go @@ -0,0 +1,61 @@ +//go:build linux + +package peoplesweep + +import ( + "crypto/sha256" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCodexRefreshRejectsChangedSourceOrUnprivateCandidate(t *testing.T) { + for _, tc := range []struct { + name string + mutate func(string, string) error + wantError error + wantSource bool + }{ + {name: "source replaced", mutate: func(authHome, _ string) error { + return os.WriteFile(filepath.Join(authHome, "auth.json"), syntheticCodexProbeAuth("user-two", "workspace-one", "external"), 0o600) + }, wantError: ErrCodexAuthSourceChanged, wantSource: true}, + {name: "candidate public", mutate: func(_, workRoot string) error { + return os.Chmod(filepath.Join(workRoot, ".codex", "auth.json"), 0o644) + }, wantError: ErrCodexAuthRefreshUnsafe}, + {name: "candidate symlink", mutate: func(_, workRoot string) error { + candidate := filepath.Join(workRoot, ".codex", "auth.json") + if err := os.Remove(candidate); err != nil { + return err + } + return os.Symlink(filepath.Join(workRoot, "refresh.json"), candidate) + }, wantError: ErrCodexAuthRefreshUnsafe}, + } { + t.Run(tc.name, func(t *testing.T) { + requireChecks := require.New(t) + authHome := t.TempDir() + workRoot := t.TempDir() + requireChecks.NoError(os.Chmod(authHome, 0o700)) + requireChecks.NoError(os.Chmod(workRoot, 0o700)) + requireChecks.NoError(os.Mkdir(filepath.Join(workRoot, ".codex"), 0o700)) + initial := syntheticCodexProbeAuth("user-one", "workspace-one", "old") + refreshed := syntheticCodexProbeAuth("user-one", "workspace-one", "new") + requireChecks.NoError(os.WriteFile(filepath.Join(authHome, "auth.json"), initial, 0o600)) + requireChecks.NoError(os.WriteFile(filepath.Join(workRoot, ".codex", "auth.json"), initial, 0o600)) + state, err := prepareCodexRefresh(authHome, workRoot) + requireChecks.NoError(err) + requireChecks.NoError(os.WriteFile(filepath.Join(workRoot, ".codex", "auth.json"), refreshed, 0o600)) + requireChecks.NoError(tc.mutate(authHome, workRoot)) + requireChecks.ErrorIs(state.commit(), tc.wantError) + contents, err := os.ReadFile(filepath.Join(authHome, "auth.json")) + requireChecks.NoError(err) + want := initial + if tc.wantSource { + want = syntheticCodexProbeAuth("user-two", "workspace-one", "external") + } + assert.Equal(t, sha256.Sum256(want), sha256.Sum256(contents)) + }) + } +} diff --git a/internal/peoplesweep/codex_rpc.go b/internal/peoplesweep/codex_rpc.go index b454ed06b..a4f1febdf 100644 --- a/internal/peoplesweep/codex_rpc.go +++ b/internal/peoplesweep/codex_rpc.go @@ -2,6 +2,7 @@ package peoplesweep import ( "bufio" + "bytes" "context" "encoding/json/jsontext" "encoding/json/v2" @@ -164,7 +165,7 @@ func (c *CodexRPCClient) Notify(ctx context.Context, method string, params any) } frame, err := json.Marshal(struct { Method string `json:"method"` - Params any `json:"params"` + Params any `json:"params,omitempty"` }{Method: method, Params: params}, json.Deterministic(true)) if err != nil { return fmt.Errorf("encode codex app-server %s notification", method) @@ -173,6 +174,18 @@ func (c *CodexRPCClient) Notify(ctx context.Context, method string, params any) return c.writeFrame(ctx, frame) } +func (c *CodexRPCClient) notifyPreparedInitialized(ctx context.Context, frame []byte) error { + c.mu.Lock() + defer c.mu.Unlock() + if err := c.initialize(); err != nil { + return err + } + if !bytes.Equal(frame, codexInitializedNotificationFrame) { + return errors.New("prepared codex initialized notification is invalid") + } + return c.writeFrame(ctx, frame) +} + func (c *CodexRPCClient) callPrepared( ctx context.Context, frame []byte, diff --git a/internal/peoplesweep/codex_rpc_internal_test.go b/internal/peoplesweep/codex_rpc_internal_test.go index 013281909..40f3df107 100644 --- a/internal/peoplesweep/codex_rpc_internal_test.go +++ b/internal/peoplesweep/codex_rpc_internal_test.go @@ -3,8 +3,13 @@ package peoplesweep import ( "bytes" "context" + "errors" "io" + "os" + "path/filepath" + "sync/atomic" "testing" + "testing/synctest" "time" "github.com/stretchr/testify/assert" @@ -28,12 +33,75 @@ type finishedCodexProcess struct { stderr io.ReadCloser } +type countingExitCodexProcess struct { + finishedCodexProcess + + kills atomic.Int64 +} + +func (p *countingExitCodexProcess) Kill() error { + p.kills.Add(1) + return nil +} + +func TestCodexForcedCleanupReportsAuthRefreshFailure(t *testing.T) { + process := &codexOwnedProcess{ + RPCProcess: finishedCodexProcess{ + stdin: discardCodexWriteCloser{Writer: io.Discard}, + stdout: io.NopCloser(bytes.NewReader(nil)), stderr: io.NopCloser(bytes.NewReader(nil)), + }, + workRoot: t.TempDir(), + refreshCommit: func() error { return ErrCodexAuthAccountChanged }, + } + client := &CodexRPCClient{Process: process} + require.NoError(t, client.initialize()) + require.ErrorIs(t, finishCodexProcess(t.Context(), process, client, true), ErrCodexAuthAccountChanged) +} + +func TestCodexCleanExitWaitsForAuthCommitBeforeRemovingWorkRoot(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + workRoot := t.TempDir() + child := &countingExitCodexProcess{} + child.finishedCodexProcess = finishedCodexProcess{ + stdin: discardCodexWriteCloser{Writer: io.Discard}, + stdout: io.NopCloser(bytes.NewReader(nil)), stderr: io.NopCloser(bytes.NewReader(nil)), + } + process := &codexOwnedProcess{ + RPCProcess: child, workRoot: workRoot, childExited: make(chan struct{}), + refreshCommit: func() error { + synctest.Sleep(150 * time.Millisecond) + return nil + }, + } + client := &CodexRPCClient{Process: process} + requireChecks.NoError(client.initialize()) + requireChecks.NoError(finishCodexProcess(t.Context(), process, client, false)) + assertChecks.Zero(child.kills.Load()) + assertChecks.NoDirExists(workRoot) + }) +} + func (p finishedCodexProcess) Stdin() io.WriteCloser { return p.stdin } func (p finishedCodexProcess) Stdout() io.ReadCloser { return p.stdout } func (p finishedCodexProcess) Stderr() io.ReadCloser { return p.stderr } func (finishedCodexProcess) Wait() error { return nil } func (finishedCodexProcess) Kill() error { return nil } +type stuckKillCodexProcess struct { + finishedCodexProcess + + release <-chan struct{} +} + +func (p stuckKillCodexProcess) Wait() error { + <-p.release + return nil +} + +func (stuckKillCodexProcess) Kill() error { return errors.New("synthetic kill failure") } + type discardCodexWriteCloser struct{ io.Writer } func (discardCodexWriteCloser) Close() error { return nil } @@ -73,3 +141,68 @@ func TestCodexCleanupBoundsStderrDrainAfterStreamClose(t *testing.T) { require.Error(t, err) assert.Less(t, time.Since(started), 500*time.Millisecond) } + +func TestCodexForceKillReportsAbandonedWaitAndRemovesAuthRoot(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + workRoot := t.TempDir() + requireChecks.NoError(os.Mkdir(filepath.Join(workRoot, ".codex"), 0o700)) + requireChecks.NoError(os.WriteFile(filepath.Join(workRoot, ".codex", "auth.json"), []byte("SYNTHETIC_AUTH"), 0o600)) + release := make(chan struct{}) + t.Cleanup(func() { close(release) }) + process := &codexOwnedProcess{RPCProcess: stuckKillCodexProcess{ + finishedCodexProcess: finishedCodexProcess{ + stdin: discardCodexWriteCloser{Writer: io.Discard}, + stdout: io.NopCloser(bytes.NewReader(nil)), stderr: io.NopCloser(bytes.NewReader(nil)), + }, + release: release, + }, workRoot: workRoot} + client := &CodexRPCClient{Process: process} + requireChecks.NoError(client.initialize()) + + err := finishCodexProcess(t.Context(), process, client, true) + requireChecks.ErrorContains(err, "process termination failed") + assertChecks.NoDirExists(workRoot) + assertChecks.NotContains(err.Error(), "SYNTHETIC_AUTH") +} + +func TestCodexForceKillReportsAuthRootRemovalFailure(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + workRoot := t.TempDir() + var attempts atomic.Int64 + process := &codexOwnedProcess{ + RPCProcess: finishedCodexProcess{ + stdin: discardCodexWriteCloser{Writer: io.Discard}, + stdout: io.NopCloser(bytes.NewReader(nil)), stderr: io.NopCloser(bytes.NewReader(nil)), + }, + workRoot: workRoot, + removeRoot: func(string) error { + attempts.Add(1) + return errors.New("SYNTHETIC_PRIVATE_PATH") + }, + } + client := &CodexRPCClient{Process: process} + requireChecks.NoError(client.initialize()) + + err := finishCodexProcess(t.Context(), process, client, true) + requireChecks.ErrorContains(err, "remove codex app-server work root") + assertChecks.NotContains(err.Error(), "SYNTHETIC_PRIVATE_PATH") + assertChecks.GreaterOrEqual(attempts.Load(), int64(2)) +} + +func TestCodexFinalReadsPinnedTotalUsage(t *testing.T) { + assertChecks := assert.New(t) + frames := []byte(`{"method":"item/completed","params":{"threadId":"thread-safe","turnId":"turn-safe","item":{"type":"agentMessage","phase":"final_answer","text":"{\"claims\":[]}"}}}` + "\n" + + `{"method":"thread/tokenUsage/updated","params":{"threadId":"thread-safe","turnId":"turn-safe","tokenUsage":{"total":{"inputTokens":21,"outputTokens":4,"cachedInputTokens":0,"reasoningOutputTokens":0,"totalTokens":25},"last":{"inputTokens":21,"outputTokens":4,"cachedInputTokens":0,"reasoningOutputTokens":0,"totalTokens":25}}}}` + "\n" + + `{"method":"turn/completed","params":{"threadId":"thread-safe","turn":{"id":"turn-safe","status":"completed"}}}` + "\n") + client := &CodexRPCClient{Process: finishedCodexProcess{ + stdin: discardCodexWriteCloser{Writer: io.Discard}, stdout: io.NopCloser(bytes.NewReader(frames)), + stderr: io.NopCloser(bytes.NewReader(nil)), + }} + final, usage, known, err := readCodexFinal(t.Context(), client, "thread-safe", "turn-safe") + require.NoError(t, err) + assertChecks.JSONEq(`{"claims":[]}`, string(final)) + assertChecks.Equal(TokenUsage{InputTokens: 21, OutputTokens: 4}, usage) + assertChecks.True(known) +} diff --git a/internal/peoplesweep/config.go b/internal/peoplesweep/config.go index 0e2424a4d..f95ed083d 100644 --- a/internal/peoplesweep/config.go +++ b/internal/peoplesweep/config.go @@ -153,6 +153,7 @@ type BudgetConfig struct { // ProviderConfig contains runtime settings and the exact outbound-data policy // that must be consented before use. type ProviderConfig struct { + PresetID string `toml:"preset_id,omitempty"` Protocol Protocol `toml:"protocol"` Endpoint string `toml:"endpoint,omitempty"` Model string `toml:"model"` @@ -207,6 +208,7 @@ func ProviderTOMLValues(provider ProviderConfig) map[string]any { // timeout. type ProviderProfile struct { Fingerprint string `json:"fingerprint"` + PresetID string `json:"preset_id,omitempty"` Protocol Protocol `json:"protocol"` Endpoint string `json:"endpoint"` Model string `json:"model"` @@ -285,6 +287,54 @@ func defaultProviderConfig() ProviderConfig { } } +// PresetProviderConfig binds a named HTTP provider to its first-party API base. +// The caller still supplies the credential source and explicit disclosure policy. +func PresetProviderConfig(id, model string) (ProviderConfig, error) { + if strings.TrimSpace(model) == "" { + return ProviderConfig{}, errors.New("people provider preset requires a model") + } + endpoint, ok := presetEndpoint(id) + if !ok { + return ProviderConfig{}, fmt.Errorf("unsupported people provider preset %q", id) + } + return ProviderConfig{ + PresetID: id, Protocol: ProtocolOpenAIChat, Endpoint: endpoint, + Model: model, Auth: AuthBearer, Credential: CredentialStored, + OutputMode: OutputModeNativeJSONSchema, TokenLimitParameter: "max_completion_tokens", + }, nil +} + +func presetEndpoint(id string) (string, bool) { + switch id { + case "openai": + return "https://api.openai.com/v1", true + case "openrouter": + return "https://openrouter.ai/api/v1", true + case "venice": + return "https://api.venice.ai/api/v1", true + default: + return "", false + } +} + +func validateProviderPreset(provider ProviderConfig) error { + if provider.PresetID == "" { + return nil + } + endpoint, ok := presetEndpoint(provider.PresetID) + if !ok { + return fmt.Errorf("unsupported people provider preset %q", provider.PresetID) + } + if provider.Endpoint != endpoint || provider.Protocol != ProtocolOpenAIChat || provider.Auth != AuthBearer { + return fmt.Errorf("people provider preset %q requires endpoint %q, protocol %q, and bearer auth", + provider.PresetID, endpoint, ProtocolOpenAIChat) + } + if provider.Credential == CredentialNone { + return fmt.Errorf("people provider preset %q requires a credential", provider.PresetID) + } + return nil +} + func applyProviderDefaults(provider *ProviderConfig) { setDefaultDuration(&provider.RequestTimeout, time.Minute) if provider.DriverVersion == "" { @@ -407,6 +457,9 @@ func (c Config) validateOperationalConfig() error { } func (c Config) validateProvider(provider ProviderConfig) error { + if err := validateProviderPreset(provider); err != nil { + return err + } var capability ProtocolCapability if provider.Protocol == ProtocolCodexAppServer { if err := requireCodexIsolationFields(provider); err != nil { @@ -558,6 +611,7 @@ func (c Config) Profile() (ProviderProfile, error) { sources := slices.Clone(provider.AllowedSources) slices.Sort(sources) profile := ProviderProfile{ + PresetID: provider.PresetID, Protocol: provider.Protocol, Model: strings.TrimSpace(provider.Model), Auth: provider.Auth, Credential: provider.Credential, CredentialRef: credentialRef, OutputMode: provider.OutputMode, TokenLimitParameter: provider.TokenLimitParameter, @@ -681,6 +735,7 @@ func CanonicalProviderProfile(p ProviderProfile) (ProviderProfile, error) { func providerConfigForProfile(p ProviderProfile) ProviderConfig { provider := ProviderConfig{ + PresetID: p.PresetID, Protocol: p.Protocol, Endpoint: p.Endpoint, Model: p.Model, Auth: p.Auth, Credential: p.Credential, OutputMode: p.OutputMode, TokenLimitParameter: p.TokenLimitParameter, ReasoningEffort: p.ReasoningEffort, diff --git a/internal/peoplesweep/credential_revision.go b/internal/peoplesweep/credential_revision.go new file mode 100644 index 000000000..50d4ab3dd --- /dev/null +++ b/internal/peoplesweep/credential_revision.go @@ -0,0 +1,137 @@ +package peoplesweep + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json/v2" + "errors" + "os" + "sync" +) + +var ErrCredentialRevisionConflict = errors.New("people provider credential revision changed") + +var credentialRevisionKey struct { + once sync.Once + key [32]byte + err error +} + +// Revision returns an opaque token for one exact stored credential state. +// It is a keyed digest, so it cannot serve as an offline verifier for the +// credential even if the underlying value has unexpectedly low entropy. +func (s *FileCredentialStore) Revision(profileName string) (string, bool, error) { + if err := validateCredentialProfileName(profileName); err != nil { + return "", false, err + } + var data []byte + present := false + err := s.withCredentialRoot("load", func(root credentialStoreRoot) error { + loaded, loadErr := root.load(profileName) + if errors.Is(loadErr, ErrCredentialNotFound) || errors.Is(loadErr, os.ErrNotExist) { + return nil + } + if loadErr != nil { + return loadErr + } + data, present = loaded, true + return nil + }) + if errors.Is(err, os.ErrNotExist) { + err = nil // The private namespace has not been created yet. + } + if err != nil { + return "", false, err + } + revision, err := credentialStateRevision(profileName, data, present) + return revision, present, err +} + +// SaveIfRevision atomically replaces one credential only while its exact +// previously observed state is current. The private namespace lock covers +// both the comparison and the existing secure publication path. +func (s *FileCredentialStore) SaveIfRevision( + profileName string, credential Credential, expected string, +) (string, error) { + if err := validateCredentialProfileName(profileName); err != nil { + return "", err + } + if err := validateStoredCredential(credential); err != nil { + return "", err + } + newData, err := json.Marshal(credentialFile{Scheme: credential.Scheme, Value: credential.Value()}, json.Deterministic(true)) + if err != nil { + return "", errors.New("serialize people provider credential") + } + var newRevision string + err = s.withCredentialRoot("save-if-revision", func(root credentialStoreRoot) error { + oldData, loadErr := root.load(profileName) + present := loadErr == nil + if loadErr != nil && !errors.Is(loadErr, ErrCredentialNotFound) && !errors.Is(loadErr, os.ErrNotExist) { + return loadErr + } + actual, revisionErr := credentialStateRevision(profileName, oldData, present) + if revisionErr != nil { + return revisionErr + } + if !hmac.Equal([]byte(actual), []byte(expected)) { + return ErrCredentialRevisionConflict + } + if err := root.save(profileName, newData); err != nil { + return err + } + newRevision, revisionErr = credentialStateRevision(profileName, newData, true) + return revisionErr + }) + return newRevision, err +} + +// DeleteIfRevision retires only the exact credential pinned by the existing +// guarded deletion path. The guard prevents a concurrent replacement from +// being deleted after the revision check. +func (s *FileCredentialStore) DeleteIfRevision(profileName, expected string) (string, error) { + guard, err := s.PreflightDelete(profileName) + if err != nil { + return "", err + } + reader, ok := guard.(interface{ credentialRevisionData() ([]byte, error) }) + if !ok { + return "", errors.Join(errors.New("people provider credential revision guard is unsupported"), guard.Close()) + } + data, err := reader.credentialRevisionData() + if err != nil { + return "", errors.Join(err, guard.Close()) + } + actual, err := credentialStateRevision(profileName, data, true) + if err != nil { + return "", errors.Join(err, guard.Close()) + } + if !hmac.Equal([]byte(actual), []byte(expected)) { + return "", errors.Join(ErrCredentialRevisionConflict, guard.Close()) + } + if err := errors.Join(s.Delete(profileName, guard), guard.Close()); err != nil { + return "", err + } + return credentialStateRevision(profileName, nil, false) +} + +func credentialStateRevision(profileName string, data []byte, present bool) (string, error) { + credentialRevisionKey.once.Do(func() { + _, credentialRevisionKey.err = rand.Read(credentialRevisionKey.key[:]) + }) + if credentialRevisionKey.err != nil { + return "", credentialRevisionKey.err + } + mac := hmac.New(sha256.New, credentialRevisionKey.key[:]) + _, _ = mac.Write([]byte("msgvault people credential revision v1\x00")) + _, _ = mac.Write([]byte(profileName)) + if present { + _, _ = mac.Write([]byte{0, 1}) + _, _ = mac.Write(data) + } else { + _, _ = mac.Write([]byte{0, 0}) + } + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil +} diff --git a/internal/peoplesweep/credential_revision_test.go b/internal/peoplesweep/credential_revision_test.go new file mode 100644 index 000000000..c293153cf --- /dev/null +++ b/internal/peoplesweep/credential_revision_test.go @@ -0,0 +1,50 @@ +package peoplesweep_test + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/peoplesweep" +) + +func TestPeopleCredentialRevisionCompareAndSave(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + if !peoplesweep.StoredCredentialsSupported() { + t.Skip("stored people provider credentials are unsupported on this platform") + } + tokensDir := filepath.Join(t.TempDir(), "tokens") + store := peoplesweep.NewFileCredentialStore(tokensDir) + initial, configured, err := store.Revision("remote") + requireChecks.NoError(err) + assertChecks.False(configured) + assertChecks.NotEmpty(initial) + first, err := store.SaveIfRevision("remote", peoplesweep.NewCredential(peoplesweep.AuthBearer, "first-secret"), initial) + requireChecks.NoError(err) + assertChecks.NotEqual(initial, first) + current, configured, err := peoplesweep.NewFileCredentialStore(tokensDir).Revision("remote") + requireChecks.NoError(err) + assertChecks.True(configured) + assertChecks.Equal(first, current) + assertChecks.NotContains(current, "first-secret") + _, err = store.SaveIfRevision("remote", peoplesweep.NewCredential(peoplesweep.AuthBearer, "stale-write"), initial) + requireChecks.ErrorIs(err, peoplesweep.ErrCredentialRevisionConflict) + loaded, err := store.Load("remote") + requireChecks.NoError(err) + assertChecks.Equal("first-secret", loaded.Value()) + second, err := store.SaveIfRevision("remote", peoplesweep.NewCredential(peoplesweep.AuthBearer, "second-secret"), first) + requireChecks.NoError(err) + assertChecks.NotEqual(first, second) + _, err = store.DeleteIfRevision("remote", first) + requireChecks.ErrorIs(err, peoplesweep.ErrCredentialRevisionConflict) + missing, err := store.DeleteIfRevision("remote", second) + requireChecks.NoError(err) + current, configured, err = store.Revision("remote") + requireChecks.NoError(err) + assertChecks.False(configured) + assertChecks.Equal(missing, current) + _, err = store.Load("remote") + requireChecks.ErrorIs(err, peoplesweep.ErrCredentialNotFound) +} diff --git a/internal/peoplesweep/credential_store_unix.go b/internal/peoplesweep/credential_store_unix.go index 44641ae81..dc0513c13 100644 --- a/internal/peoplesweep/credential_store_unix.go +++ b/internal/peoplesweep/credential_store_unix.go @@ -216,6 +216,34 @@ const ( func (*unixCredentialDeleteGuard) credentialDeleteGuard() {} +// credentialRevisionData reads the pinned target while its namespace lock is +// held. Reentering the regular store load path here would deadlock on that +// lock and could observe a different pathname target. +func (g *unixCredentialDeleteGuard) credentialRevisionData() ([]byte, error) { + g.mu.Lock() + defer g.mu.Unlock() + if g.state != credentialDeleteGuardReady || g.target == nil || g.target.credentialFD < 0 { + return nil, errors.New("people provider credential deletion guard is not ready") + } + var data []byte + buffer := make([]byte, 32*1024) + for offset := int64(0); ; { + count, err := unix.Pread(g.target.credentialFD, buffer, offset) + if err != nil { + return nil, fmt.Errorf("read pinned people provider credential revision: %w", err) + } + if count == 0 { + break + } + data = append(data, buffer[:count]...) + offset += int64(count) + } + if len(data) == 0 { + return nil, ErrCredentialNotFound + } + return data, nil +} + func (*unixCredentialCleanupGuard) credentialCleanupGuard() {} func (*unixCredentialDeleteGuard) String() string { @@ -363,6 +391,9 @@ func (s *FileCredentialStore) openExistingCredentialDelete( }() target.tokensFD, retErr = unix.Open(s.tokensDir, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_DIRECTORY|unix.O_NOFOLLOW, 0) + if errors.Is(retErr, os.ErrNotExist) { + return nil, fmt.Errorf("%w for profile %q: %w", ErrCredentialNotFound, profileName, retErr) + } if retErr != nil { return nil, fmt.Errorf("open existing people provider tokens directory without following symlinks: %w", retErr) } @@ -376,6 +407,9 @@ func (s *FileCredentialStore) openExistingCredentialDelete( target.rootFD, retErr = unix.Openat(target.tokensFD, credentialNamespace, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_DIRECTORY|unix.O_NOFOLLOW, 0) + if errors.Is(retErr, os.ErrNotExist) { + return nil, fmt.Errorf("%w for profile %q: %w", ErrCredentialNotFound, profileName, retErr) + } if retErr != nil { return nil, fmt.Errorf("open existing people provider credential directory without following symlinks: %w", retErr) } diff --git a/internal/peoplesweep/driver_registry.go b/internal/peoplesweep/driver_registry.go index 9ac1a887c..d882619d8 100644 --- a/internal/peoplesweep/driver_registry.go +++ b/internal/peoplesweep/driver_registry.go @@ -24,15 +24,27 @@ var ErrCodexIsolationUnreleased = errors.New("codex app-server isolation is not // configured at selection time because its executable is operational config, // not part of the immutable provider profile. type DriverRegistry struct { - drivers map[Protocol]StructuredDriver - commands CommandStarter - isolation CodexIsolationGate + drivers map[Protocol]StructuredDriver + commands CommandStarter + isolation CodexIsolationGate + codexAuthHome string } func NewDriverRegistry( httpClient *http.Client, commands CommandStarter, isolation CodexIsolationGate, +) (*DriverRegistry, error) { + return NewDriverRegistryWithCodexAuthHome(httpClient, commands, isolation, "") +} + +// NewDriverRegistryWithCodexAuthHome binds an explicitly selected daemon +// credential directory to Codex drivers. Other protocols ignore this path. +func NewDriverRegistryWithCodexAuthHome( + httpClient *http.Client, + commands CommandStarter, + isolation CodexIsolationGate, + authHome string, ) (*DriverRegistry, error) { drivers := map[Protocol]StructuredDriver{ ProtocolOpenAIChat: NewOpenAIChatDriver(httpClient), @@ -42,7 +54,7 @@ func NewDriverRegistry( } return &DriverRegistry{ drivers: drivers, - commands: commands, isolation: isolation, + commands: commands, isolation: isolation, codexAuthHome: authHome, }, nil } @@ -105,7 +117,7 @@ func (r *DriverRegistry) Driver( if err := attestation.Close(); err != nil { return nil, err } - return NewCodexAppServerDriver(canonical, r.commands, r.isolation) + return NewCodexAppServerDriverWithAuthHome(canonical, r.commands, r.isolation, r.codexAuthHome) } // CanonicalCodexProviderVersion derives a safe provider identity from the diff --git a/internal/peoplesweep/enrollment_draft.go b/internal/peoplesweep/enrollment_draft.go new file mode 100644 index 000000000..0c016f73f --- /dev/null +++ b/internal/peoplesweep/enrollment_draft.go @@ -0,0 +1,108 @@ +package peoplesweep + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "strings" + "sync" + "time" +) + +const EnrollmentDraftLifetime = 10 * time.Minute + +var ( + ErrEnrollmentDraftNotFound = errors.New("people provider enrollment draft was not found") + ErrEnrollmentDraftActive = errors.New("a people provider enrollment draft is already active") + ErrEnrollmentDraftInvalid = errors.New("people provider enrollment draft input is invalid") +) + +// EnrollmentDraft is a short lived, model-less enrollment context. It carries +// no Codex token or API key and cannot authorize an archive request. +type EnrollmentDraft struct { + ID string `json:"id"` + Provider string `json:"provider"` + Model string `json:"model,omitempty"` + ExpiresAt time.Time `json:"expires_at"` + owner string +} + +// EnrollmentDrafts owns transient setup state for one daemon. A browser +// session or terminal session supplies its own opaque owner identifier. +type EnrollmentDrafts struct { + mu sync.Mutex + now func() time.Time + byID map[string]EnrollmentDraft + byOwner map[string]string +} + +func NewEnrollmentDrafts(now func() time.Time) *EnrollmentDrafts { + if now == nil { + now = time.Now + } + return &EnrollmentDrafts{ + now: now, byID: make(map[string]EnrollmentDraft), byOwner: make(map[string]string), + } +} + +// Create allocates one active Codex draft per owner before model discovery. +func (d *EnrollmentDrafts) Create(owner, provider string) (EnrollmentDraft, error) { + if strings.TrimSpace(owner) != owner || owner == "" || len(owner) > 128 || provider != "codex" { + return EnrollmentDraft{}, ErrEnrollmentDraftInvalid + } + d.mu.Lock() + defer d.mu.Unlock() + now := d.now().UTC() + d.expire(now) + if _, exists := d.byOwner[owner]; exists { + return EnrollmentDraft{}, ErrEnrollmentDraftActive + } + var random [32]byte + if _, err := rand.Read(random[:]); err != nil { + return EnrollmentDraft{}, fmt.Errorf("generate enrollment draft ID: %w", err) + } + draft := EnrollmentDraft{ + ID: base64.RawURLEncoding.EncodeToString(random[:]), + Provider: provider, ExpiresAt: now.Add(EnrollmentDraftLifetime), owner: owner, + } + d.byID[draft.ID] = draft + d.byOwner[owner] = draft.ID + return draft, nil +} + +// Get returns a draft only to its owner. Expired drafts are removed first. +func (d *EnrollmentDrafts) Get(owner, id string) (EnrollmentDraft, error) { + d.mu.Lock() + defer d.mu.Unlock() + d.expire(d.now().UTC()) + draft, exists := d.byID[id] + if !exists || draft.owner != owner { + return EnrollmentDraft{}, ErrEnrollmentDraftNotFound + } + return draft, nil +} + +// Cancel removes only the owner's exact draft. +func (d *EnrollmentDrafts) Cancel(owner, id string) error { + d.mu.Lock() + defer d.mu.Unlock() + d.expire(d.now().UTC()) + draft, exists := d.byID[id] + if !exists || draft.owner != owner { + return ErrEnrollmentDraftNotFound + } + delete(d.byID, id) + delete(d.byOwner, owner) + return nil +} + +// expire runs under mu. Draft state is never persisted or logged. +func (d *EnrollmentDrafts) expire(now time.Time) { + for id, draft := range d.byID { + if !now.Before(draft.ExpiresAt) { + delete(d.byID, id) + delete(d.byOwner, draft.owner) + } + } +} diff --git a/internal/peoplesweep/enrollment_draft_test.go b/internal/peoplesweep/enrollment_draft_test.go new file mode 100644 index 000000000..a52de3bee --- /dev/null +++ b/internal/peoplesweep/enrollment_draft_test.go @@ -0,0 +1,48 @@ +package peoplesweep + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEnrollmentDraftsBindOwnerAndExpire(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + now := time.Date(2026, time.September, 23, 5, 0, 0, 0, time.UTC) + drafts := NewEnrollmentDrafts(func() time.Time { return now }) + draft, err := drafts.Create("browser-session-a", "codex") + requireChecks.NoError(err) + assertChecks.NotEmpty(draft.ID) + assertChecks.Empty(draft.Model) + assertChecks.Equal(now.Add(EnrollmentDraftLifetime), draft.ExpiresAt) + _, err = drafts.Get("browser-session-b", draft.ID) + requireChecks.ErrorIs(err, ErrEnrollmentDraftNotFound) + _, err = drafts.Create("browser-session-a", "codex") + requireChecks.ErrorIs(err, ErrEnrollmentDraftActive) + + now = now.Add(EnrollmentDraftLifetime + time.Second) + _, err = drafts.Get("browser-session-a", draft.ID) + requireChecks.ErrorIs(err, ErrEnrollmentDraftNotFound) + second, err := drafts.Create("browser-session-a", "codex") + requireChecks.NoError(err) + assertChecks.NotEqual(draft.ID, second.ID) +} + +func TestEnrollmentDraftCancellationIsOwnerBound(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + drafts := NewEnrollmentDrafts(time.Now) + draft, err := drafts.Create("terminal-a", "codex") + requireChecks.NoError(err) + requireChecks.ErrorIs(drafts.Cancel("terminal-b", draft.ID), ErrEnrollmentDraftNotFound) + _, err = drafts.Get("terminal-a", draft.ID) + requireChecks.NoError(err) + requireChecks.NoError(drafts.Cancel("terminal-a", draft.ID)) + _, err = drafts.Get("terminal-a", draft.ID) + requireChecks.ErrorIs(err, ErrEnrollmentDraftNotFound) + _, err = drafts.Create("terminal-a", "codex") + assertChecks.NoError(err) +} diff --git a/internal/peoplesweep/openai_chat.go b/internal/peoplesweep/openai_chat.go index 845e314c7..f5eb14823 100644 --- a/internal/peoplesweep/openai_chat.go +++ b/internal/peoplesweep/openai_chat.go @@ -54,6 +54,9 @@ func (d *OpenAIChatDriver) Prepare( "name": request.SchemaName, "strict": true, "schema": request.JSONSchema, }, } + if profile.PresetID == "openrouter" { + body["provider"] = map[string]any{"require_parameters": true} + } case OutputModeJSONObject: body["response_format"] = map[string]any{"type": "json_object"} case OutputModePromptJSON: diff --git a/internal/peoplesweep/openai_chat_test.go b/internal/peoplesweep/openai_chat_test.go index 5cfd0c415..069856727 100644 --- a/internal/peoplesweep/openai_chat_test.go +++ b/internal/peoplesweep/openai_chat_test.go @@ -35,18 +35,18 @@ func TestOpenAIChatFixturesEmitSavedCapabilitiesExactly(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { newAssert := assert.New - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) want, err := os.ReadFile("testdata/providers/" + test.fixture) - require.NoError(err) + requireChecks.NoError(err) want = bytes.TrimSpace(want) var got []byte server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert := newAssert(t) + assertChecks := newAssert(t) got, err = io.ReadAll(r.Body) - assert.NoError(err) + assertChecks.NoError(err) _, err = io.WriteString(w, `{"model":"reported-build","choices":[{"message":{"content":"{\"ok\":true}"}}]}`) - assert.NoError(err) + assertChecks.NoError(err) })) defer server.Close() @@ -59,13 +59,13 @@ func TestOpenAIChatFixturesEmitSavedCapabilitiesExactly(t *testing.T) { AllowedSources: []peoplesweep.SourceClass{peoplesweep.SourceConversationText}, SourceSince: "2025-01-01", }) profile, err := config.Profile() - require.NoError(err) + requireChecks.NoError(err) transport := peoplesweep.NewOpenAIChatDriver(server.Client()) prepared, err := transport.Prepare(profile, structuredTestRequest()) - require.NoError(err) + requireChecks.NoError(err) _, err = transport.GeneratePrepared(t.Context(), profile, peoplesweep.NewCredential(peoplesweep.AuthBearer, "test-key"), prepared) - require.NoError(err) - assert.Equal(string(want), string(got)) + requireChecks.NoError(err) + assertChecks.Equal(string(want), string(got)) }) } } @@ -159,19 +159,19 @@ func generateOpenAIChatJSON( } func TestOpenAIChatDriverGeneratesStructuredJSON(t *testing.T) { - assert := assert.New(t) + assertChecks := assert.New(t) var captured capturedChatRequest server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(http.MethodPost, r.Method) - assert.Equal("/v1/chat/completions", r.URL.Path) - assert.Equal("application/json", r.Header.Get("Content-Type")) - assert.Equal("Bearer test-key", r.Header.Get("Authorization")) - assert.NoError(json.NewDecoder(r.Body).Decode(&captured)) + assertChecks.Equal(http.MethodPost, r.Method) + assertChecks.Equal("/v1/chat/completions", r.URL.Path) + assertChecks.Equal("application/json", r.Header.Get("Content-Type")) + assertChecks.Equal("Bearer test-key", r.Header.Get("Authorization")) + assertChecks.NoError(json.NewDecoder(r.Body).Decode(&captured)) w.Header().Set("X-Request-ID", "req-1") _, err := io.WriteString(w, `{"model":"gpt-test","choices":[{"message":{"role":"assistant","content":"{\"ok\":true}"},"finish_reason":"stop"}],`+ `"usage":{"prompt_tokens":7,"completion_tokens":3,"total_tokens":10}}`) - assert.NoError(err) + assertChecks.NoError(err) })) defer server.Close() @@ -181,24 +181,24 @@ func TestOpenAIChatDriverGeneratesStructuredJSON(t *testing.T) { peoplesweep.NewOpenAIChatDriver(server.Client()), "test-key", request, ) require.NoError(t, err) - assert.JSONEq(`{"ok":true}`, string(got.Output)) - assert.Equal("req-1", got.ProviderRequestID) - assert.Equal(int64(7), got.Usage.InputTokens) - assert.Equal(int64(3), got.Usage.OutputTokens) - - assert.Equal("gpt-test", captured.Model) - assert.Equal(32, captured.MaxCompletionTokens) - assert.Nil(captured.MaxTokens, "deprecated max_tokens must not be sent") + assertChecks.JSONEq(`{"ok":true}`, string(got.Output)) + assertChecks.Equal("req-1", got.ProviderRequestID) + assertChecks.Equal(int64(7), got.Usage.InputTokens) + assertChecks.Equal(int64(3), got.Usage.OutputTokens) + + assertChecks.Equal("gpt-test", captured.Model) + assertChecks.Equal(32, captured.MaxCompletionTokens) + assertChecks.Nil(captured.MaxTokens, "deprecated max_tokens must not be sent") require.Len(t, captured.Messages, 2) - assert.Equal("system", captured.Messages[0].Role) - assert.Equal("Return one JSON value that strictly matches the supplied JSON Schema.", + assertChecks.Equal("system", captured.Messages[0].Role) + assertChecks.Equal("Return one JSON value that strictly matches the supplied JSON Schema.", captured.Messages[0].Content) - assert.Equal("user", captured.Messages[1].Role) - assert.Equal(request.InputText, captured.Messages[1].Content) - assert.Equal("json_schema", captured.ResponseFormat.Type) - assert.Equal(request.SchemaName, captured.ResponseFormat.JSONSchema.Name) - assert.True(captured.ResponseFormat.JSONSchema.Strict) - assert.JSONEq(string(request.JSONSchema), + assertChecks.Equal("user", captured.Messages[1].Role) + assertChecks.Equal(request.InputText, captured.Messages[1].Content) + assertChecks.Equal("json_schema", captured.ResponseFormat.Type) + assertChecks.Equal(request.SchemaName, captured.ResponseFormat.JSONSchema.Name) + assertChecks.True(captured.ResponseFormat.JSONSchema.Strict) + assertChecks.JSONEq(string(request.JSONSchema), string(captured.ResponseFormat.JSONSchema.Schema)) } @@ -213,30 +213,30 @@ func TestOpenAIChatDriverDistinguishesMissingUsageFromReportedZero(t *testing.T) } { t.Run(test.name, func(t *testing.T) { newAssert := assert.New - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - assert := newAssert(t) + assertChecks := newAssert(t) _, err := io.WriteString(w, `{"model":"gpt-test","choices":[{"message":{"content":"{\"ok\":true}"}}]`+test.usage+`}`) - assert.NoError(err) + assertChecks.NoError(err) })) defer server.Close() profile := providerTestProfile(t, server.URL+"/v1", false) driver := peoplesweep.NewOpenAIChatDriver(server.Client()) prepared, err := driver.Prepare(profile, structuredTestRequest()) - require.NoError(err) + requireChecks.NoError(err) response, err := driver.GeneratePrepared(t.Context(), profile, peoplesweep.NewCredential(peoplesweep.AuthBearer, "test-key"), prepared) - require.NoError(err) - assert.Equal(test.usageKnown, response.UsageKnown) - assert.Equal(peoplesweep.TokenUsage{}, response.Usage) + requireChecks.NoError(err) + assertChecks.Equal(test.usageKnown, response.UsageKnown) + assertChecks.Equal(peoplesweep.TokenUsage{}, response.Usage) }) } } func TestOpenAIChatDriverRejectsMismatchedTypedCredentialWithoutNetwork(t *testing.T) { - assert := assert.New(t) - require := require.New(t) + assertChecks := assert.New(t) + requireChecks := require.New(t) var requests atomic.Int64 server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requests.Add(1) @@ -245,13 +245,13 @@ func TestOpenAIChatDriverRejectsMismatchedTypedCredentialWithoutNetwork(t *testi profile := providerTestProfile(t, server.URL+"/v1", false) driver := peoplesweep.NewOpenAIChatDriver(server.Client()) prepared, err := driver.Prepare(profile, structuredTestRequest()) - require.NoError(err) + requireChecks.NoError(err) _, err = driver.GeneratePrepared(t.Context(), profile, peoplesweep.NewCredential(peoplesweep.AuthXAPIKey, "typed-secret-canary"), prepared) - require.ErrorContains(err, "scheme does not match") - assert.NotContains(err.Error(), "typed-secret-canary") - assert.Zero(requests.Load()) + requireChecks.ErrorContains(err, "scheme does not match") + assertChecks.NotContains(err.Error(), "typed-secret-canary") + assertChecks.Zero(requests.Load()) } func TestOpenAIChatDriverOmitsAuthorizationForAnonymousLoopback(t *testing.T) { @@ -279,12 +279,12 @@ func TestOpenAIChatDriverSanitizesHTTPFailures(t *testing.T) { http.StatusInternalServerError, } { t.Run(http.StatusText(status), func(t *testing.T) { - assert := assert.New(t) + assertChecks := assert.New(t) server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("X-Request-ID", "req-secret-safe") w.WriteHeader(status) _, err := io.WriteString(w, `{"error":{"message":"provider-secret-body"}}`) - assert.NoError(err) + assertChecks.NoError(err) })) defer server.Close() @@ -295,10 +295,10 @@ func TestOpenAIChatDriverSanitizesHTTPFailures(t *testing.T) { require.Error(t, err) var providerErr *peoplesweep.ProviderError require.ErrorAs(t, err, &providerErr) - assert.Equal(status, providerErr.StatusCode) - assert.Equal("req-secret-safe", providerErr.RequestID) - assert.NotContains(err.Error(), "provider-secret-body") - assert.NotContains(err.Error(), "test-key") + assertChecks.Equal(status, providerErr.StatusCode) + assertChecks.Equal("req-secret-safe", providerErr.RequestID) + assertChecks.NotContains(err.Error(), "provider-secret-body") + assertChecks.NotContains(err.Error(), "test-key") }) } } @@ -366,13 +366,13 @@ func TestOpenAIChatDriverRejectsInvalidTokenUsage(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - require := require.New(t) - assert := assert.New(t) + requireChecks := require.New(t) + assertChecks := assert.New(t) body := `{"model":"gpt-test","choices":[{"message":{"content":"{\"ok\":true}"}}],"usage":{` + test.usage + `},"unsafe":"provider-secret-body"}` server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, err := io.WriteString(w, body) - assert.NoError(err) + assertChecks.NoError(err) })) defer server.Close() @@ -380,23 +380,23 @@ func TestOpenAIChatDriverRejectsInvalidTokenUsage(t *testing.T) { t.Context(), providerTestProfile(t, server.URL+"/v1", false), peoplesweep.NewOpenAIChatDriver(server.Client()), "test-key", structuredTestRequest(), ) - require.ErrorIs(err, peoplesweep.ErrInvalidStructuredOutput) - assert.NotContains(err.Error(), "provider-secret-body") - assert.Equal(test.wantInput, response.Usage.InputTokens) - assert.Equal(test.wantOutput, response.Usage.OutputTokens) + requireChecks.ErrorIs(err, peoplesweep.ErrInvalidStructuredOutput) + assertChecks.NotContains(err.Error(), "provider-secret-body") + assertChecks.Equal(test.wantInput, response.Usage.InputTokens) + assertChecks.Equal(test.wantOutput, response.Usage.OutputTokens) }) } } func TestOpenAIChatDriverDoesNotFollowRedirects(t *testing.T) { - require := require.New(t) - assert := assert.New(t) + requireChecks := require.New(t) + assertChecks := assert.New(t) var redirectedRequests atomic.Int64 target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { redirectedRequests.Add(1) _, err := io.WriteString(w, `{"model":"gpt-test","choices":[{"message":{"content":"{\"ok\":true}"}}],"usage":{}}`) - assert.NoError(err) + assertChecks.NoError(err) })) defer target.Close() @@ -411,12 +411,12 @@ func TestOpenAIChatDriverDoesNotFollowRedirects(t *testing.T) { t.Context(), providerTestProfile(t, origin.URL+"/v1", false), peoplesweep.NewOpenAIChatDriver(origin.Client()), "test-key", structuredTestRequest(), ) - require.Error(err) + requireChecks.Error(err) var providerErr *peoplesweep.ProviderError - require.ErrorAs(err, &providerErr) - assert.Equal(http.StatusTemporaryRedirect, providerErr.StatusCode) - assert.Equal("redirect-req", providerErr.RequestID) - assert.Zero(redirectedRequests.Load(), "redirect target must receive no provider request") + requireChecks.ErrorAs(err, &providerErr) + assertChecks.Equal(http.StatusTemporaryRedirect, providerErr.StatusCode) + assertChecks.Equal("redirect-req", providerErr.RequestID) + assertChecks.Zero(redirectedRequests.Load(), "redirect target must receive no provider request") } func TestOpenAIChatDriverRejectsMalformedResponsesWithoutEchoingThem(t *testing.T) { @@ -465,8 +465,8 @@ func TestOpenAIChatDriverBoundsResponseBody(t *testing.T) { } func TestOpenAIChatDriverHonorsCancellationAndClientTimeout(t *testing.T) { - require := require.New(t) - assert := assert.New(t) + requireChecks := require.New(t) + assertChecks := assert.New(t) var requests atomic.Int64 release := make(chan struct{}) server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -488,8 +488,8 @@ func TestOpenAIChatDriverHonorsCancellationAndClientTimeout(t *testing.T) { cancelled, profile, peoplesweep.NewOpenAIChatDriver(server.Client()), "test-key", structuredTestRequest(), ) - require.ErrorIs(err, context.Canceled) - assert.Zero(requests.Load()) + requireChecks.ErrorIs(err, context.Canceled) + assertChecks.Zero(requests.Load()) timeoutClient := server.Client() timeoutClient.Timeout = 100 * time.Millisecond @@ -497,11 +497,11 @@ func TestOpenAIChatDriverHonorsCancellationAndClientTimeout(t *testing.T) { t.Context(), profile, peoplesweep.NewOpenAIChatDriver(timeoutClient), "test-key", structuredTestRequest(), ) - require.Error(err) - assert.Truef( + requireChecks.Error(err) + assertChecks.Truef( errors.Is(err, context.DeadlineExceeded) || strings.Contains(err.Error(), "timeout"), "timeout error = %T: %v", err, err) - assert.Equal(int64(1), requests.Load()) + assertChecks.Equal(int64(1), requests.Load()) } func TestOpenAIChatPreparedRequestUsesExactHTTPBody(t *testing.T) { @@ -526,6 +526,34 @@ func TestOpenAIChatPreparedRequestUsesExactHTTPBody(t *testing.T) { assert.Equal(t, want, received) } +func TestProviderPresetOpenRouterStrictRequestRequiresParameters(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + config := validConfig() + provider := activeProvider(config) + provider.PresetID = "openrouter" + provider.Endpoint = "https://openrouter.ai/api/v1" + setActiveProvider(&config, provider) + profile, err := config.Profile() + requireChecks.NoError(err) + + driver := peoplesweep.NewOpenAIChatDriver(nil) + prepared, err := driver.Prepare(profile, structuredTestRequest()) + requireChecks.NoError(err) + var routed map[string]any + requireChecks.NoError(json.Unmarshal(prepared.WireRequest(), &routed)) + assertChecks.Equal(map[string]any{"require_parameters": true}, routed["provider"]) + + generic := validConfig() + genericProfile, err := generic.Profile() + requireChecks.NoError(err) + prepared, err = driver.Prepare(genericProfile, structuredTestRequest()) + requireChecks.NoError(err) + var ordinary map[string]any + requireChecks.NoError(json.Unmarshal(prepared.WireRequest(), &ordinary)) + assertChecks.NotContains(ordinary, "provider") +} + func TestOpenAIChatDriverRejectsForgedPreparedWire(t *testing.T) { var requests atomic.Int64 server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { diff --git a/internal/peoplesweep/provider.go b/internal/peoplesweep/provider.go index 940cdaf61..d481464d5 100644 --- a/internal/peoplesweep/provider.go +++ b/internal/peoplesweep/provider.go @@ -305,3 +305,10 @@ type CodexIsolationGate interface { Verify(ctx context.Context, executable string, expectedBoundary string) (CodexAttestation, error) ReverifyForLaunch(attestation CodexAttestation) error } + +// CodexLauncher is the app-server's attestation and process entry point. +// Start rechecks the attestation immediately before launching the process. +type CodexLauncher interface { + Verify(ctx context.Context, executable string) (CodexAttestation, error) + Start(ctx context.Context, attestation CodexAttestation, authHome string) (RPCProcess, error) +} diff --git a/internal/peoplesweep/provider_preset_test.go b/internal/peoplesweep/provider_preset_test.go new file mode 100644 index 000000000..20d3897ca --- /dev/null +++ b/internal/peoplesweep/provider_preset_test.go @@ -0,0 +1,69 @@ +package peoplesweep_test + +import ( + "bytes" + "testing" + + "github.com/BurntSushi/toml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/peoplesweep" +) + +func TestProviderPresetRoundTripAndFingerprint(t *testing.T) { + assertChecks := assert.New(t) + requireChecks := require.New(t) + base := validConfig() + legacy, err := base.Profile() + requireChecks.NoError(err) + assertChecks.NotContains(string(legacy.PolicyJSON), "preset_id") + + provider := activeProvider(base) + provider.PresetID = "openrouter" + provider.Endpoint = "https://openrouter.ai/api/v1" + setActiveProvider(&base, provider) + profile, err := base.Profile() + requireChecks.NoError(err) + assertChecks.Equal("openrouter", profile.PresetID) + assertChecks.NotEqual(legacy.Fingerprint, profile.Fingerprint) + assertChecks.Contains(string(profile.PolicyJSON), `"preset_id":"openrouter"`) + requireChecks.NoError(profile.Validate()) + stored, err := peoplesweep.CanonicalStoredProviderProfile(profile) + requireChecks.NoError(err) + assertChecks.Equal(profile.Fingerprint, stored.Fingerprint) + + var encoded bytes.Buffer + requireChecks.NoError(toml.NewEncoder(&encoded).Encode(provider)) + var decoded peoplesweep.ProviderConfig + _, err = toml.Decode(encoded.String(), &decoded) + requireChecks.NoError(err) + assertChecks.Equal("openrouter", decoded.PresetID) + assertChecks.Equal("openrouter", peoplesweep.ProviderTOMLValues(decoded)["preset_id"]) +} + +func TestProviderPresetRejectsEndpointProtocolAndAuthSwap(t *testing.T) { + for _, test := range []struct { + name string + change func(*peoplesweep.ProviderConfig) + }{ + {"endpoint", func(p *peoplesweep.ProviderConfig) { p.Endpoint = "https://elsewhere.example.test/v1" }}, + {"protocol", func(p *peoplesweep.ProviderConfig) { p.Protocol = peoplesweep.ProtocolOpenAIResponses }}, + {"auth", func(p *peoplesweep.ProviderConfig) { p.Auth = peoplesweep.AuthXAPIKey }}, + } { + t.Run(test.name, func(t *testing.T) { + config := validConfig() + provider, err := peoplesweep.PresetProviderConfig("venice", "venice/model") + require.NoError(t, err) + provider.Credential = peoplesweep.CredentialEnv + provider.CredentialEnv = "TEST_KEY" + provider.RetentionPosture = "operator_asserted" + provider.TrainingPosture = "operator_asserted" + provider.AllowedSources = []peoplesweep.SourceClass{peoplesweep.SourceConversationText} + provider.SourceSince = "2025-01-01" + provider.RequestTimeout = activeProvider(config).RequestTimeout + test.change(&provider) + setActiveProvider(&config, provider) + require.ErrorContains(t, config.Validate(), "preset") + }) + } +} diff --git a/internal/peoplesweep/testdata/codex_containment_probe/main.go b/internal/peoplesweep/testdata/codex_containment_probe/main.go new file mode 100644 index 000000000..a71b6b134 --- /dev/null +++ b/internal/peoplesweep/testdata/codex_containment_probe/main.go @@ -0,0 +1,128 @@ +package main + +import ( + "bufio" + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "net" + "os" + "syscall" + "time" +) + +// This executable is built statically by the opt-in Codex containment test. +// Codex command/exec runs it inside the production Bubblewrap boundary. +func main() { + if len(os.Args) != 3 && !(len(os.Args) == 4 && os.Args[1] == "copy") { + fmt.Println("INVALID_ARGUMENTS") + os.Exit(2) + } + var ( + value string + err error + ) + switch os.Args[1] { + case "read": + var contents []byte + contents, err = os.ReadFile(os.Args[2]) + value = string(contents) + case "write": + err = os.WriteFile(os.Args[2], []byte("SYNTHETIC_PROBE_WRITTEN"), 0o600) + case "copy": + var contents []byte + contents, err = os.ReadFile(os.Args[2]) + if err == nil { + err = os.WriteFile(os.Args[3], contents, 0o600) + } + case "egress": + var connection net.Conn + connection, err = net.DialTimeout("tcp", os.Args[2], 500*time.Millisecond) + if connection != nil { + _ = connection.Close() + } + case "proxy": + var connection net.Conn + connection, err = net.DialTimeout("tcp", "127.0.0.1:3128", 500*time.Millisecond) + if err == nil { + defer connection.Close() + _ = connection.SetDeadline(time.Now().Add(2 * time.Second)) + _, err = fmt.Fprintf(connection, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", os.Args[2], os.Args[2]) + if err == nil { + value, err = bufio.NewReader(connection).ReadString('\n') + } + if err == nil { + value = value[:len(value)-2] + } + } + case "tls": + connection, dialErr := net.DialTimeout("tcp", "127.0.0.1:3128", 2*time.Second) + if dialErr != nil { + fmt.Println("TLS_PROXY_FAILED") + return + } + defer connection.Close() + _ = connection.SetDeadline(time.Now().Add(8 * time.Second)) + _, err = fmt.Fprintf(connection, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", os.Args[2], os.Args[2]) + if err != nil { + fmt.Println("TLS_PROXY_FAILED") + return + } + reader := bufio.NewReader(connection) + status, readErr := reader.ReadString('\n') + if readErr != nil || status != "HTTP/1.1 200 Connection Established\r\n" { + fmt.Println("TLS_PROXY_FAILED") + return + } + for { + line, readErr := reader.ReadString('\n') + if readErr != nil { + fmt.Println("TLS_PROXY_FAILED") + return + } + if line == "\r\n" { + break + } + } + host, _, splitErr := net.SplitHostPort(os.Args[2]) + if splitErr != nil { + fmt.Println("TLS_PROXY_FAILED") + return + } + secure := tls.Client(connection, &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}) + if handshakeErr := secure.HandshakeContext(context.Background()); handshakeErr != nil { + var unknownAuthority x509.UnknownAuthorityError + if errors.As(handshakeErr, &unknownAuthority) { + fmt.Println("TLS_UNTRUSTED") + } else { + fmt.Println("TLS_HANDSHAKE_FAILED") + } + return + } + fmt.Println("TLS_VERIFIED") + return + default: + fmt.Println("INVALID_OPERATION") + os.Exit(2) + } + if err == nil { + if os.Args[1] == "proxy" { + if value == "HTTP/1.1 403 Forbidden" { + fmt.Printf("DENIED %s\n", value) + } else { + fmt.Printf("ALLOWED %s\n", value) + } + return + } + fmt.Printf("ALLOWED %s\n", value) + return + } + var errno syscall.Errno + if errors.As(err, &errno) { + fmt.Printf("DENIED errno=%d\n", errno) + return + } + fmt.Println("DENIED non-syscall-error") +} diff --git a/internal/personenrollment/service.go b/internal/personenrollment/service.go new file mode 100644 index 000000000..2302f2bf1 --- /dev/null +++ b/internal/personenrollment/service.go @@ -0,0 +1,270 @@ +package personenrollment + +import ( + "context" + "errors" + "fmt" + "maps" + "slices" + + "go.kenn.io/msgvault/internal/config" + "go.kenn.io/msgvault/internal/peoplesweep" +) + +var ( + ErrCheckRequired = errors.New("an exact successful people provider check is required") + ErrConsentRequired = errors.New("exact people provider consent is required") + ErrProfileExists = errors.New("people provider profile already exists") + ErrProfileMissing = errors.New("people provider profile was not found") + ErrInvalidProfile = errors.New("people provider profile is invalid") + ErrProfileActive = errors.New("active people provider must be disabled before removal") + ErrOnlyProfile = errors.New("selected people provider is the only remaining profile") +) + +type CheckConsentStore interface { + HasSuccessfulPersonInferenceCheck(ctx context.Context, fingerprint string) (bool, error) + HasActivePersonInferenceConsent(ctx context.Context, fingerprint string) (bool, error) +} + +type RevocationStore interface { + CheckConsentStore + RevokePersonInferenceConsent(ctx context.Context, fingerprint, actor string) (bool, error) +} + +// Service writes named policies with config ETags and gates selection on the +// exact check and consent records. It does not hold credentials in memory. +type Service struct { + configPath string + store CheckConsentStore +} + +func NewService(configPath string, store CheckConsentStore) *Service { + return &Service{configPath: configPath, store: store} +} + +type Selection struct { + Name string `json:"name"` + Fingerprint string `json:"fingerprint"` + ETag string `json:"etag"` +} + +// CreateProfile publishes a policy but leaves selection and enablement alone. +func (s *Service) CreateProfile(ifMatch, name string, provider peoplesweep.ProviderConfig) (Selection, error) { + if err := peoplesweep.ValidateProviderProfileName(name); err != nil { + return Selection{}, err + } + snapshot, configured, err := s.readConfig(ifMatch) + if err != nil { + return Selection{}, err + } + if _, exists := configured.People.Sweep.Providers[name]; exists { + return Selection{}, ErrProfileExists + } + proposed := configured.People.Sweep + proposed.Providers = make(map[string]peoplesweep.ProviderConfig, len(configured.People.Sweep.Providers)+1) + maps.Copy(proposed.Providers, configured.People.Sweep.Providers) + proposed.Providers[name] = provider + proposed.Provider = peoplesweep.ProviderSelection{Name: name} + proposed.Enabled = true + proposed.ApplyDefaults() + profile, err := proposed.Profile() + if err != nil { + return Selection{}, fmt.Errorf("%w: %w", ErrInvalidProfile, err) + } + edits := []config.TableEdit{{ + Path: []string{"people", "sweep", "providers", name}, + Values: peoplesweep.ProviderTOMLValues(provider), InsertOnly: true, + }} + if err := config.ValidateConfigTableEdits(snapshot, edits); err != nil { + return Selection{}, err + } + written, err := config.EditConfigTables(s.configPath, ifMatch, edits) + if err != nil { + return Selection{}, err + } + return Selection{Name: name, Fingerprint: profile.Fingerprint, ETag: written.ETag}, nil +} + +// SelectProfile enables an already checked and consented policy. The running +// daemon still uses its startup policy until its owner restarts it. +func (s *Service) SelectProfile(ctx context.Context, ifMatch, name string) (Selection, error) { + if err := peoplesweep.ValidateProviderProfileName(name); err != nil { + return Selection{}, err + } + _, configured, err := s.readConfig(ifMatch) + if err != nil { + return Selection{}, err + } + if _, exists := configured.People.Sweep.Providers[name]; !exists { + return Selection{}, ErrProfileMissing + } + selected := configured.People.Sweep + selected.Provider = peoplesweep.ProviderSelection{Name: name} + selected.Enabled = true + profile, err := selected.Profile() + if err != nil { + return Selection{}, err + } + if s.store == nil { + return Selection{}, errors.New("people provider check and consent store is unavailable") + } + checked, err := s.store.HasSuccessfulPersonInferenceCheck(ctx, profile.Fingerprint) + if err != nil { + return Selection{}, err + } + if !checked { + return Selection{}, ErrCheckRequired + } + consented, err := s.store.HasActivePersonInferenceConsent(ctx, profile.Fingerprint) + if err != nil { + return Selection{}, err + } + if !consented { + return Selection{}, ErrConsentRequired + } + written, err := config.EditConfigTables(s.configPath, ifMatch, []config.TableEdit{{ + Path: []string{"people", "sweep"}, + Values: map[string]any{"enabled": true, "provider": name}, + }}) + if err != nil { + return Selection{}, err + } + return Selection{Name: name, Fingerprint: profile.Fingerprint, ETag: written.ETag}, nil +} + +// Disable revokes authority for the configured and still-running policies +// before changing the saved enable flag. A daemon with a pending restart can +// therefore finish in-flight requests under their original policy, while new +// dispatches fail the existing consent gate immediately. +func (s *Service) Disable( + ctx context.Context, ifMatch, runningFingerprint, actor string, +) (Selection, error) { + snapshot, configured, err := s.readConfig(ifMatch) + if err != nil { + return Selection{}, err + } + revocations, ok := s.store.(RevocationStore) + if !ok { + return Selection{}, errors.New("people provider consent store is unavailable") + } + fingerprints := make(map[string]struct{}) + if runningFingerprint != "" { + fingerprints[runningFingerprint] = struct{}{} + } + configuredName := configured.People.Sweep.Provider.Name + if configuredName != "" { + candidate := configured.People.Sweep + candidate.Enabled = true + if profile, profileErr := candidate.Profile(); profileErr == nil { + fingerprints[profile.Fingerprint] = struct{}{} + } + } + for fingerprint := range fingerprints { + if _, err := revocations.RevokePersonInferenceConsent(ctx, fingerprint, actor); err != nil { + return Selection{}, err + } + } + written, err := config.EditConfigTables(s.configPath, snapshot.ETag, []config.TableEdit{{ + Path: []string{"people", "sweep"}, Values: map[string]any{"enabled": false}, + }}) + if err != nil { + return Selection{}, err + } + return Selection{Name: configuredName, ETag: written.ETag}, nil +} + +// RemoveProfile removes a saved policy after revoking its authority. Stored +// credentials are pinned and preflighted before changing the config, then +// deleted only after the config edit succeeds. +func (s *Service) RemoveProfile( + ctx context.Context, ifMatch, name, actor string, credentials peoplesweep.CredentialStore, +) (removed Selection, retErr error) { + if err := peoplesweep.ValidateProviderProfileName(name); err != nil { + return Selection{}, err + } + snapshot, configured, err := s.readConfig(ifMatch) + if err != nil { + return Selection{}, err + } + provider, exists := configured.People.Sweep.Providers[name] + if !exists { + return Selection{}, ErrProfileMissing + } + selected := configured.People.Sweep.Provider.Name == name + if selected && configured.People.Sweep.Enabled { + return Selection{}, ErrProfileActive + } + edits := make([]config.TableEdit, 0, 2) + if selected { + names := make([]string, 0, len(configured.People.Sweep.Providers)-1) + for candidate := range configured.People.Sweep.Providers { + if candidate != name { + names = append(names, candidate) + } + } + if len(names) == 0 { + return Selection{}, ErrOnlyProfile + } + slices.Sort(names) + edits = append(edits, config.TableEdit{ + Path: []string{"people", "sweep"}, Values: map[string]any{"provider": names[0]}, + }) + } + edits = append(edits, config.TableEdit{Path: []string{"people", "sweep", "providers", name}, Remove: true}) + profileConfig := configured.People.Sweep + profileConfig.Enabled = true + profileConfig.Provider = peoplesweep.ProviderSelection{Name: name} + profile, err := profileConfig.Profile() + if err != nil { + return Selection{}, err + } + if err := config.ValidateConfigTableEdits(snapshot, edits); err != nil { + return Selection{}, err + } + var guard peoplesweep.CredentialDeleteGuard + if provider.Credential == peoplesweep.CredentialStored { + if credentials == nil { + return Selection{}, errors.New("people provider credential store is unavailable") + } + guard, err = credentials.PreflightDelete(name) + if err != nil && !errors.Is(err, peoplesweep.ErrCredentialNotFound) { + return Selection{}, err + } + if guard != nil { + defer func() { retErr = errors.Join(retErr, guard.Close()) }() + } + } + revocations, ok := s.store.(RevocationStore) + if !ok { + return Selection{}, errors.New("people provider consent store is unavailable") + } + if _, err := revocations.RevokePersonInferenceConsent(ctx, profile.Fingerprint, actor); err != nil { + return Selection{}, err + } + written, err := config.EditConfigTables(s.configPath, snapshot.ETag, edits) + if err != nil { + return Selection{}, err + } + if guard != nil { + if err := credentials.Delete(name, guard); err != nil { + _, restoreErr := config.RestoreConfigFile(s.configPath, written, snapshot) + return Selection{}, errors.Join(err, restoreErr) + } + } + return Selection{Name: name, Fingerprint: profile.Fingerprint, ETag: written.ETag}, nil +} + +func (s *Service) readConfig(ifMatch string) (config.ConfigFile, *config.Config, error) { + snapshot, err := config.ReadConfigFile(s.configPath) + if err != nil { + return config.ConfigFile{}, nil, err + } + if snapshot.ETag != ifMatch { + return config.ConfigFile{}, nil, config.ErrConfigConflict + } + loaded, err := config.LoadConfigFile(snapshot, "") + if err != nil { + return config.ConfigFile{}, nil, err + } + return snapshot, loaded, nil +} diff --git a/internal/personenrollment/service_test.go b/internal/personenrollment/service_test.go new file mode 100644 index 000000000..cf170cbc6 --- /dev/null +++ b/internal/personenrollment/service_test.go @@ -0,0 +1,167 @@ +package personenrollment + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/config" + "go.kenn.io/msgvault/internal/peoplesweep" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil" +) + +func TestServiceRemoveStoredProfileCredentials(t *testing.T) { + if !peoplesweep.StoredCredentialsSupported() { + t.Skip("stored credentials require Unix permissions") + } + for _, state := range []string{"never saved", "already cleared", "missing lock marker"} { + t.Run(state, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + configured := config.NewDefaultConfig() + configured.HomeDir = t.TempDir() + configured.Data.DataDir = configured.HomeDir + require.NoError(configured.Save()) + service := NewService(configured.ConfigFilePath(), testutil.NewTestStore(t)) + before, err := config.ReadConfigFile(configured.ConfigFilePath()) + require.NoError(err) + provider, err := peoplesweep.PresetProviderConfig("openai", "example-model") + require.NoError(err) + provider.RetentionPosture = "operator-confirmed" + provider.TrainingPosture = "operator-confirmed" + provider.AllowedSources = []peoplesweep.SourceClass{peoplesweep.SourceConversationText} + provider.SourceSince = "2025-01-01" + created, err := service.CreateProfile(before.ETag, "remote", provider) + require.NoError(err) + credentials := peoplesweep.NewFileCredentialStore(configured.TokensDir()) + if state != "never saved" { + require.NoError(os.MkdirAll(configured.TokensDir(), 0o700)) + require.NoError(credentials.Save("remote", peoplesweep.NewCredential(peoplesweep.AuthBearer, "synthetic-key"))) + if state == "already cleared" { + guard, err := credentials.PreflightDelete("remote") + require.NoError(err) + require.NoError(credentials.Delete("remote", guard)) + require.NoError(guard.Close()) + } else { + require.NoError(os.Remove(filepath.Join(configured.TokensDir(), "people-providers", ".credentials.lock"))) + } + } + _, err = service.RemoveProfile(t.Context(), created.ETag, "remote", "test", credentials) + if state == "missing lock marker" { + require.Error(err) + after, err := config.ReadConfigFile(configured.ConfigFilePath()) + require.NoError(err) + assert.Equal(created.ETag, after.ETag) + return + } + require.NoError(err) + after, err := config.Load(configured.ConfigFilePath(), "") + require.NoError(err) + assert.NotContains(after.People.Sweep.Providers, "remote") + }) + } +} + +func TestServiceCreatesProfileWithoutSelectionAndRequiresCheckAndConsent(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + configured := config.NewDefaultConfig() + configured.HomeDir = t.TempDir() + require.NoError(configured.Save()) + st := testutil.NewTestStore(t) + service := NewService(configured.ConfigFilePath(), st) + before, err := config.ReadConfigFile(configured.ConfigFilePath()) + require.NoError(err) + provider := peoplesweep.ProviderConfig{ + Protocol: peoplesweep.ProtocolOpenAIChat, Endpoint: "https://api.example.test/v1", + Model: "example-model", Auth: peoplesweep.AuthBearer, + Credential: peoplesweep.CredentialEnv, CredentialEnv: "EXAMPLE_API_KEY", + OutputMode: peoplesweep.OutputModeNativeJSONSchema, + TokenLimitParameter: "max_completion_tokens", + RetentionPosture: "operator-confirmed", TrainingPosture: "operator-confirmed", + AllowedSources: []peoplesweep.SourceClass{peoplesweep.SourceConversationText}, + SourceSince: "2025-01-01", RequestTimeout: time.Minute, + } + created, err := service.CreateProfile(before.ETag, "remote", provider) + require.NoError(err) + assert.Equal("remote", created.Name) + assert.NotEmpty(created.Fingerprint) + after, err := config.Load(configured.ConfigFilePath(), "") + require.NoError(err) + assert.False(after.People.Sweep.Enabled) + assert.NotEqual("remote", after.People.Sweep.Provider.Name) + _, err = service.CreateProfile(before.ETag, "other", provider) + require.ErrorIs(err, config.ErrConfigConflict) + _, err = service.SelectProfile(context.Background(), created.ETag, "remote") + require.ErrorIs(err, ErrCheckRequired) + + profileConfig := after.People.Sweep + profileConfig.Enabled = true + profileConfig.Provider = peoplesweep.ProviderSelection{Name: "remote"} + profile, err := profileConfig.Profile() + require.NoError(err) + _, err = st.EnsurePersonInferenceProfile(context.Background(), profile) + require.NoError(err) + require.NoError(st.RecordPersonInferenceCheck(context.Background(), store.PersonInferenceCheck{ + ProfileFingerprint: profile.Fingerprint, CheckedAt: time.Now(), + DriverVersion: profile.DriverVersion, OutputMode: profile.OutputMode, + ModelVersion: profile.Model, + })) + _, err = service.SelectProfile(context.Background(), created.ETag, "remote") + require.ErrorIs(err, ErrConsentRequired) + _, _, err = st.GrantPersonInferenceConsent(context.Background(), profile.Fingerprint, "test") + require.NoError(err) + selected, err := service.SelectProfile(context.Background(), created.ETag, "remote") + require.NoError(err) + assert.Equal(profile.Fingerprint, selected.Fingerprint) + after, err = config.Load(configured.ConfigFilePath(), "") + require.NoError(err) + assert.True(after.People.Sweep.Enabled) + assert.Equal("remote", after.People.Sweep.Provider.Name) +} + +func TestServiceRemoveProfileRevokesAndKeepsSelectedFallback(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + configured := config.NewDefaultConfig() + configured.HomeDir = t.TempDir() + require.NoError(configured.Save()) + st := testutil.NewTestStore(t) + service := NewService(configured.ConfigFilePath(), st) + before, err := config.ReadConfigFile(configured.ConfigFilePath()) + require.NoError(err) + provider := peoplesweep.ProviderConfig{ + Protocol: peoplesweep.ProtocolOpenAIChat, Endpoint: "https://api.example.test/v1", + Model: "example-model", Auth: peoplesweep.AuthBearer, + Credential: peoplesweep.CredentialEnv, CredentialEnv: "EXAMPLE_API_KEY", + OutputMode: peoplesweep.OutputModeNativeJSONSchema, TokenLimitParameter: "max_completion_tokens", + RetentionPosture: "operator-confirmed", TrainingPosture: "operator-confirmed", + AllowedSources: []peoplesweep.SourceClass{peoplesweep.SourceConversationText}, + SourceSince: "2025-01-01", RequestTimeout: time.Minute, + } + first, err := service.CreateProfile(before.ETag, "first", provider) + require.NoError(err) + second, err := service.CreateProfile(first.ETag, "second", provider) + require.NoError(err) + _, err = service.RemoveProfile(t.Context(), second.ETag, "missing", "test", nil) + require.ErrorIs(err, ErrProfileMissing) + selected, err := config.EditConfigTables(configured.ConfigFilePath(), second.ETag, + []config.TableEdit{{Path: []string{"people", "sweep"}, Values: map[string]any{"provider": "second", "enabled": false}}}) + require.NoError(err) + removed, err := service.RemoveProfile(t.Context(), selected.ETag, "second", "test", nil) + require.NoError(err) + assert.Equal("second", removed.Name) + after, err := config.Load(configured.ConfigFilePath(), "") + require.NoError(err) + assert.NotContains(after.People.Sweep.Providers, "second") + assert.Equal("default", after.People.Sweep.Provider.Name) + removedFirst, err := service.RemoveProfile(t.Context(), removed.ETag, "first", "test", nil) + require.NoError(err) + _, err = service.RemoveProfile(t.Context(), removedFirst.ETag, "default", "test", nil) + assert.ErrorIs(err, ErrOnlyProfile) +} diff --git a/internal/store/person_inference_check.go b/internal/store/person_inference_check.go index 45614b3a7..2de51423d 100644 --- a/internal/store/person_inference_check.go +++ b/internal/store/person_inference_check.go @@ -121,6 +121,25 @@ func (s *Store) HasSuccessfulPersonInferenceCheck( return check != nil, nil } +// InvalidatePersonInferenceCheck removes the old capability proof before a +// credential or authenticated account changes. The immutable profile and its +// historical consent record remain available for audit. +func (s *Store) InvalidatePersonInferenceCheck(ctx context.Context, fingerprint string) (bool, error) { + if !validLowerSHA256(fingerprint) { + return false, errors.New("people inference check requires a lowercase SHA-256 fingerprint") + } + result, err := s.db.ExecContext(ctx, + `DELETE FROM person_inference_checks WHERE profile_fingerprint = ?`, fingerprint) + if err != nil { + return false, fmt.Errorf("invalidate people inference check: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return false, fmt.Errorf("count invalidated people inference checks: %w", err) + } + return rows > 0, nil +} + func validatePersonInferenceCheck(check PersonInferenceCheck) error { if !validLowerSHA256(check.ProfileFingerprint) { return errors.New("people inference check requires a lowercase SHA-256 fingerprint") diff --git a/internal/store/person_inference_check_invalidate_test.go b/internal/store/person_inference_check_invalidate_test.go new file mode 100644 index 000000000..ce6012c86 --- /dev/null +++ b/internal/store/person_inference_check_invalidate_test.go @@ -0,0 +1,34 @@ +package store_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil" +) + +func TestInvalidatePersonInferenceCheckRemovesExactCheck(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st := testutil.NewTestStore(t) + profile := inferenceTestProfile(t) + _, err := st.EnsurePersonInferenceProfile(t.Context(), profile) + require.NoError(err) + require.NoError(st.RecordPersonInferenceCheck(t.Context(), store.PersonInferenceCheck{ + ProfileFingerprint: profile.Fingerprint, CheckedAt: time.Now(), + DriverVersion: profile.DriverVersion, OutputMode: profile.OutputMode, + ModelVersion: profile.Model, + })) + changed, err := st.InvalidatePersonInferenceCheck(t.Context(), profile.Fingerprint) + require.NoError(err) + assert.True(changed) + verified, err := st.HasSuccessfulPersonInferenceCheck(t.Context(), profile.Fingerprint) + require.NoError(err) + assert.False(verified) + changed, err = st.InvalidatePersonInferenceCheck(t.Context(), profile.Fingerprint) + require.NoError(err) + assert.False(changed) +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 85e982cef..1d00bce5c 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1257,6 +1257,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.handleSettingsLoaded(msg) case settingsSavedMsg: return m.handleSettingsSaved(msg) + case peopleControlsLoadedMsg, peopleControlsActionMsg: + return m.handlePeopleInferenceControlMsg(msg) + case codexLoginStartedMsg, codexLoginPolledMsg, codexModelsLoadedMsg, + codexProfileSavedMsg, codexCheckedMsg, codexConsentedMsg, + codexSelectedMsg, codexStatusLoadedMsg, codexPollTickMsg: + return m.handleCodexSettingsMsg(msg) // People messages are delegated before the shared Email handlers. case peopleSearchDebounceMsg: return m.handlePeopleSearchDebounce(msg) diff --git a/internal/tui/settings_keys.go b/internal/tui/settings_keys.go index 9b333f75f..094aeb185 100644 --- a/internal/tui/settings_keys.go +++ b/internal/tui/settings_keys.go @@ -15,6 +15,12 @@ func (m Model) settingsIsNarrow() bool { } func (m Model) handleSettingsKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + if m.settings.peopleControls.active { + return m.handlePeopleInferenceControlKey(msg) + } + if m.settings.codex.active { + return m.handleCodexSettingsKey(msg) + } if msg.String() == keyNameCtrlC { m.quitting = true return m, tea.Quit @@ -42,6 +48,10 @@ func (m Model) handleSettingsKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) } switch msg.String() { + case "p": + return m.openCodexSettings() + case "i": + return m.openPeopleInferenceControls() case "ctrl+s": return m.saveSettings() case keyNameEsc: diff --git a/internal/tui/settings_people_controls.go b/internal/tui/settings_people_controls.go new file mode 100644 index 000000000..c99e71ed4 --- /dev/null +++ b/internal/tui/settings_people_controls.go @@ -0,0 +1,273 @@ +package tui + +import ( + "context" + "errors" + "strings" + + tea "charm.land/bubbletea/v2" + "go.kenn.io/msgvault/internal/textutil" +) + +// PeopleInferenceControlBackend provides status and reversible safety controls +// without exposing the device-login onboarding journey. +type PeopleInferenceControlBackend interface { + LoadPeopleInferenceStatus(ctx context.Context) (PeopleInferenceStatus, error) + RevokePeopleInferenceConsent(ctx context.Context, profile, fingerprint string) (PeopleInferenceStatus, error) + DisablePeopleInference(ctx context.Context, fingerprint string) (PeopleInferenceStatus, error) + RemovePeopleInferenceProfile(ctx context.Context, profile, fingerprint string) (PeopleInferenceStatus, error) +} + +type peopleInferenceControlState struct { + active bool + loading bool + pending bool + resolvingMutation bool + exitBlocked bool + requestID uint64 + status *PeopleInferenceStatus + confirm string + confirmProfile string + confirmFingerprint string + message string + ctx context.Context + cancel context.CancelFunc +} + +type peopleControlsLoadedMsg struct { + status PeopleInferenceStatus + err error + requestID uint64 +} + +type peopleControlsActionMsg struct { + status PeopleInferenceStatus + operation string + err error + requestID uint64 +} + +func (m Model) peopleInferenceControlBackend() PeopleInferenceControlBackend { + backend, _ := m.settingsBackend.(PeopleInferenceControlBackend) + return backend +} + +func (m Model) openPeopleInferenceControls() (tea.Model, tea.Cmd) { + if m.settings.dirty() { + m.settings.status = "Save or discard settings drafts before changing people inference." + return m, nil + } + backend := m.peopleInferenceControlBackend() + if backend == nil { + return m, nil + } + m.settingsRequestID++ + ctx, cancel := context.WithCancel(context.Background()) + m.settings.peopleControls = peopleInferenceControlState{ + active: true, loading: true, requestID: m.settingsRequestID, ctx: ctx, cancel: cancel, + } + return m, m.loadPeopleInferenceControls(backend) +} + +func (m Model) loadPeopleInferenceControls(backend PeopleInferenceControlBackend) tea.Cmd { + ctx, id := m.settings.peopleControls.ctx, m.settings.peopleControls.requestID + return func() tea.Msg { + status, err := backend.LoadPeopleInferenceStatus(ctx) + return peopleControlsLoadedMsg{status: status, err: err, requestID: id} + } +} + +func (m Model) closePeopleInferenceControls() Model { + if m.settings.peopleControls.cancel != nil { + m.settings.peopleControls.cancel() + } + m.settings.peopleControls = peopleInferenceControlState{} + return m +} + +func (m Model) handlePeopleInferenceControlKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + s := &m.settings.peopleControls + if s.pending || (s.resolvingMutation && s.loading) { + if msg.String() == keyNameEsc || msg.String() == keyNameCtrlC { + s.exitBlocked = true + } + return m, nil + } + switch msg.String() { + case keyNameCtrlC: + m = m.closePeopleInferenceControls() + m.quitting = true + return m, tea.Quit + case keyNameEsc: + if s.confirm != "" { + s.confirm = "" + return m, nil + } + return m.closePeopleInferenceControls(), nil + } + backend := m.peopleInferenceControlBackend() + if s.confirm != "" { + switch msg.String() { + case "n", "N": + s.confirm = "" + return m, nil + case "y", "Y": + operation, profile, fingerprint := s.confirm, s.confirmProfile, s.confirmFingerprint + s.confirm = "" + s.pending = true + ctx, id := s.ctx, s.requestID + return m, func() tea.Msg { + var status PeopleInferenceStatus + var err error + switch operation { + case "disable": + status, err = backend.DisablePeopleInference(ctx, fingerprint) + case "revoke": + status, err = backend.RevokePeopleInferenceConsent(ctx, profile, fingerprint) + case "remove": + status, err = backend.RemovePeopleInferenceProfile(ctx, profile, fingerprint) + } + return peopleControlsActionMsg{status: status, operation: operation, err: err, requestID: id} + } + } + return m, nil + } + if msg.String() == "r" { + s.loading = true + return m, m.loadPeopleInferenceControls(backend) + } + if s.loading || s.status == nil { + return m, nil + } + switch msg.String() { + case "d": + if s.status.ConfiguredEnabled && s.status.ConfiguredFingerprint != "" { + s.confirm = "disable" + s.confirmFingerprint = s.status.ConfiguredFingerprint + s.confirmProfile = s.status.Configured + } + case "v": + if s.status.Configured != "" && s.status.ConfiguredFingerprint != "" { + s.confirm = "revoke" + s.confirmFingerprint = s.status.ConfiguredFingerprint + s.confirmProfile = s.status.Configured + } + case "x": + if !s.status.ConfiguredEnabled && s.status.Configured != "" && s.status.ConfiguredFingerprint != "" { + s.confirm = "remove" + s.confirmFingerprint = s.status.ConfiguredFingerprint + s.confirmProfile = s.status.Configured + } + } + return m, nil +} + +func (m Model) handlePeopleInferenceControlMsg(msg tea.Msg) (tea.Model, tea.Cmd) { + s := &m.settings.peopleControls + switch v := msg.(type) { + case peopleControlsLoadedMsg: + if !s.active || v.requestID != s.requestID { + return m, nil + } + s.loading = false + s.resolvingMutation = false + s.exitBlocked = false + if v.err != nil { + s.message = "Could not load people inference status: " + v.err.Error() + s.status = nil + m.settings.peopleInferenceStatus = nil + m.settings.status = "People inference status could not be refreshed; reopen controls to refresh." + m.settings.statusIsError = true + return m, nil + } + s.status = &v.status + m.settings.peopleInferenceStatus = &v.status + m.settings.status = "" + m.settings.statusIsError = false + case peopleControlsActionMsg: + if !s.active || v.requestID != s.requestID { + return m, nil + } + s.pending = false + s.exitBlocked = false + if v.err != nil { + var conflict *SettingsConflictError + if errors.As(v.err, &conflict) && conflict.Scope == SettingsConflictConfig { + s.message = "People inference settings changed; review the refreshed status before retrying." + } else { + s.message = "Could not confirm people inference change: " + v.err.Error() + } + s.status = nil + m.settings.peopleInferenceStatus = nil + s.loading = true + s.resolvingMutation = true + return m, m.loadPeopleInferenceControls(m.peopleInferenceControlBackend()) + } + s.status = &v.status + m.settings.peopleInferenceStatus = &v.status + switch v.operation { + case "disable": + s.message = "People inference disabled." + case "remove": + s.message = "Profile removed." + default: + s.message = "Consent revoked." + } + } + return m, nil +} + +func (m Model) renderPeopleInferenceControls() string { + s := m.settings.peopleControls + lines := []string{m.styles.titleBar.Render("People inference controls"), ""} + if s.loading { + lines = append(lines, "Loading people inference status…") + } + if status := s.status; status != nil { + configured := textutil.SanitizeTerminal(status.Configured) + if configured == "" { + configured = "none" + } + lines = append(lines, "Configured: "+configured, "Fingerprint: "+textutil.SanitizeTerminal(status.ConfiguredFingerprint)) + if status.ConfiguredEnabled { + lines = append(lines, "People inference: enabled") + } else { + lines = append(lines, "People inference: disabled") + } + lines = append(lines, "Running: "+textutil.SanitizeTerminal(status.Running)) + if status.PendingRestart { + lines = append(lines, "Restart the daemon to apply the change.") + } + } + if s.confirm != "" { + switch s.confirm { + case "disable": + lines = append(lines, "Disable people inference and revoke active consent?") + case "remove": + lines = append(lines, "Remove profile "+textutil.SanitizeTerminal(s.confirmProfile)+" and its stored credential if any?") + default: + lines = append(lines, "Revoke consent for "+textutil.SanitizeTerminal(s.confirmProfile)+"?") + } + lines = append(lines, "Fingerprint: "+textutil.SanitizeTerminal(s.confirmFingerprint), "[y] Confirm [n/Esc] Cancel") + } else if s.pending { + lines = append(lines, "Applying people inference change…") + } else { + actions := "[d] Disable [v] Revoke consent" + if s.status != nil && !s.status.ConfiguredEnabled && s.status.ConfiguredFingerprint != "" { + actions += " [x] Remove profile" + } + lines = append(lines, actions+" [r] Refresh [Esc] Back") + } + if s.message != "" { + lines = append(lines, textutil.SanitizeTerminalMultiline(s.message)) + } + if s.exitBlocked { + lines = append(lines, "Change in progress; wait for the result before leaving.") + } + width := max(m.width, 20) + wrapped := []string{lines[0]} + for _, line := range lines[1:] { + wrapped = append(wrapped, wrapText(line, width)...) + } + return strings.Join(wrapped, "\n") +} diff --git a/internal/tui/settings_people_controls_test.go b/internal/tui/settings_people_controls_test.go new file mode 100644 index 000000000..31011f73f --- /dev/null +++ b/internal/tui/settings_people_controls_test.go @@ -0,0 +1,265 @@ +package tui + +import ( + "context" + "errors" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSettingsPeopleControlsDisableRequiresConfirmation(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + backend := &fakePeopleInferenceControlBackend{ + loads: []SettingsSnapshot{settingsFixture()}, + status: PeopleInferenceStatus{Configured: "codex-profile", ConfiguredFingerprint: "fp-1", ConfiguredEnabled: true, + Running: "codex-profile", RunningFingerprint: "fp-1", RunningEnabled: true}, + } + model := loadedSettingsModelWithBackend(t, &backend.fakeSettingsBackend) + model.settingsBackend = backend + assert.Contains(stripANSI(model.renderView()), "[i] People inference controls") + assert.NotContains(stripANSI(model.renderView()), "[p] People inference") + model, noOnboarding := sendKey(t, model, key('p')) + assert.Nil(noOnboarding) + + model, load := sendKey(t, model, key('i')) + require.NotNil(load) + model = sendSettingsMsg(t, model, load()) + assert.Contains(stripANSI(model.renderView()), "fp-1") + model, confirm := sendKey(t, model, key('d')) + assert.Nil(confirm) + assert.Contains(stripANSI(model.renderView()), "Disable people inference") + assert.Contains(stripANSI(model.renderView()), "fp-1") + assert.Empty(backend.disabledFingerprint) + model, cancel := sendKey(t, model, key('n')) + assert.Nil(cancel) + assert.Empty(backend.disabledFingerprint) + model, _ = sendKey(t, model, key('d')) + model, disable := sendKey(t, model, key('y')) + require.NotNil(disable) + model = sendSettingsMsg(t, model, disable()) + assert.Equal("fp-1", backend.disabledFingerprint) + assert.False(model.settings.peopleControls.status.ConfiguredEnabled) + assert.Contains(stripANSI(model.renderView()), "disabled") + assert.Contains(stripANSI(model.renderView()), "Restart") + model, back := sendKey(t, model, keyEsc()) + assert.Nil(back) + assert.Contains(stripANSI(model.renderView()), "People inference: codex-profile") +} + +func TestSettingsPeopleControlsRevokeUsesReviewedFingerprint(t *testing.T) { + require := require.New(t) + + assert := assert.New(t) + backend := &fakePeopleInferenceControlBackend{ + loads: []SettingsSnapshot{settingsFixture()}, + status: PeopleInferenceStatus{Configured: "router", ConfiguredFingerprint: "fp-router", ConfiguredEnabled: true}, + } + model := loadedSettingsModelWithBackend(t, &backend.fakeSettingsBackend) + model.settingsBackend = backend + model, load := sendKey(t, model, key('i')) + model = sendSettingsMsg(t, model, load()) + model, _ = sendKey(t, model, key('v')) + assert.Contains(stripANSI(model.renderView()), "Revoke consent") + assert.Contains(stripANSI(model.renderView()), "fp-router") + model, revoke := sendKey(t, model, key('y')) + require.NotNil(revoke) + model = sendSettingsMsg(t, model, revoke()) + assert.Equal("router", backend.revokedProfile) + assert.Equal("fp-router", backend.revokedFingerprint) + assert.Contains(stripANSI(model.renderView()), "Consent revoked") +} + +func TestSettingsPeopleControlsRemoveRequiresDisabledProfileAndConfirmation(t *testing.T) { + require := require.New(t) + + assert := assert.New(t) + backend := &fakePeopleInferenceControlBackend{ + loads: []SettingsSnapshot{settingsFixture()}, + status: PeopleInferenceStatus{Configured: "router", ConfiguredFingerprint: "fp-router", ConfiguredEnabled: true}, + } + model := loadedSettingsModelWithBackend(t, &backend.fakeSettingsBackend) + model.settingsBackend = backend + model, load := sendKey(t, model, key('i')) + model = sendSettingsMsg(t, model, load()) + model, blocked := sendKey(t, model, key('x')) + assert.Nil(blocked) + assert.Empty(model.settings.peopleControls.confirm) + assert.Empty(backend.removedProfile) + + backend.status.ConfiguredEnabled = false + model, refresh := sendKey(t, model, key('r')) + model = sendSettingsMsg(t, model, refresh()) + model, confirm := sendKey(t, model, key('x')) + assert.Nil(confirm) + view := stripANSI(model.renderView()) + assert.Contains(view, "Remove profile router") + assert.Contains(view, "fp-router") + assert.Contains(view, "stored credential") + model, _ = sendKey(t, model, key('n')) + assert.Empty(backend.removedProfile) + model, _ = sendKey(t, model, key('x')) + model, remove := sendKey(t, model, key('y')) + require.NotNil(remove) + model = sendSettingsMsg(t, model, remove()) + assert.Equal("router", backend.removedProfile) + assert.Equal("fp-router", backend.removedFingerprint) + assert.Contains(stripANSI(model.renderView()), "Profile removed") + model, _ = sendKey(t, model, keyEsc()) + assert.Contains(stripANSI(model.renderView()), "People inference: backup") +} + +func TestSettingsPeopleControlsReloadAfterConfigConflict(t *testing.T) { + require := require.New(t) + + assert := assert.New(t) + backend := &fakePeopleInferenceControlBackend{ + loads: []SettingsSnapshot{settingsFixture()}, + status: PeopleInferenceStatus{Configured: "router", ConfiguredFingerprint: "fp-old", ConfiguredEnabled: true}, + disableErr: &SettingsConflictError{Scope: SettingsConflictConfig, Err: errors.New("HTTP 412")}, + } + model := loadedSettingsModelWithBackend(t, &backend.fakeSettingsBackend) + model.settingsBackend = backend + model, load := sendKey(t, model, key('i')) + model = sendSettingsMsg(t, model, load()) + model, _ = sendKey(t, model, key('d')) + model, disable := sendKey(t, model, key('y')) + backend.status.ConfiguredFingerprint = "fp-new" + updated, reload := model.Update(disable()) + model = asModel(t, updated) + require.NotNil(reload) + assert.Nil(model.settings.peopleInferenceStatus) + model, blocked := sendKey(t, model, keyEsc()) + assert.Nil(blocked) + assert.True(model.settings.peopleControls.active) + model, blocked = sendKey(t, model, tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + assert.Nil(blocked) + assert.False(model.quitting) + model = sendSettingsMsg(t, model, reload()) + assert.Equal("fp-old", backend.disabledFingerprint) + assert.Contains(stripANSI(model.renderView()), "fp-new") + assert.Contains(stripANSI(model.renderView()), "changed") + assert.Empty(model.settings.peopleControls.confirm) + model, back := sendKey(t, model, keyEsc()) + assert.Nil(back) + assert.Contains(stripANSI(model.renderView()), "fp-new") +} + +func TestSettingsPeopleControlsWaitsForPendingMutationBeforeExit(t *testing.T) { + for _, operation := range []string{"disable", "revoke", "remove"} { + t.Run(operation, func(t *testing.T) { + require := require.New(t) + + assert := assert.New(t) + backend := &fakePeopleInferenceControlBackend{ + loads: []SettingsSnapshot{settingsFixture()}, + status: PeopleInferenceStatus{Configured: "router", ConfiguredFingerprint: "fp-1", ConfiguredEnabled: true}, + } + if operation == "remove" { + backend.status.ConfiguredEnabled = false + } + model := loadedSettingsModelWithBackend(t, &backend.fakeSettingsBackend) + model.settingsBackend = backend + model, load := sendKey(t, model, key('i')) + model = sendSettingsMsg(t, model, load()) + keyCode := 'd' + switch operation { + case "revoke": + keyCode = 'v' + case "remove": + keyCode = 'x' + } + model, _ = sendKey(t, model, key(keyCode)) + model, mutate := sendKey(t, model, key('y')) + require.NotNil(mutate) + model, blocked := sendKey(t, model, keyEsc()) + assert.Nil(blocked) + assert.True(model.settings.peopleControls.active) + assert.Contains(stripANSI(model.renderView()), "wait for the result") + model, blocked = sendKey(t, model, tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + assert.Nil(blocked) + assert.False(model.quitting) + model = sendSettingsMsg(t, model, mutate()) + assert.NotNil(model.settings.peopleInferenceStatus) + model, back := sendKey(t, model, keyEsc()) + assert.Nil(back) + assert.False(model.settings.peopleControls.active) + }) + } +} + +func TestSettingsPeopleControlsClearsCacheWhenConflictRefreshFails(t *testing.T) { + require := require.New(t) + + assert := assert.New(t) + backend := &fakePeopleInferenceControlBackend{ + loads: []SettingsSnapshot{settingsFixture()}, + status: PeopleInferenceStatus{Configured: "router", ConfiguredFingerprint: "fp-old", ConfiguredEnabled: true}, + disableErr: &SettingsConflictError{Scope: SettingsConflictConfig, Err: errors.New("HTTP 412")}, + loadErrors: []error{nil, errors.New("daemon unavailable")}, + } + model := loadedSettingsModelWithBackend(t, &backend.fakeSettingsBackend) + model.settingsBackend = backend + model, load := sendKey(t, model, key('i')) + model = sendSettingsMsg(t, model, load()) + model, _ = sendKey(t, model, key('d')) + model, disable := sendKey(t, model, key('y')) + updated, reload := model.Update(disable()) + model = asModel(t, updated) + require.NotNil(reload) + model = sendSettingsMsg(t, model, reload()) + assert.Nil(model.settings.peopleInferenceStatus) + model, back := sendKey(t, model, keyEsc()) + assert.Nil(back) + assert.NotContains(stripANSI(model.renderView()), "People inference: router") + assert.Contains(stripANSI(model.renderView()), "status could not be refreshed") +} + +type fakePeopleInferenceControlBackend struct { + fakeSettingsBackend + + status PeopleInferenceStatus + disabledFingerprint string + revokedProfile string + revokedFingerprint string + removedProfile string + removedFingerprint string + disableErr error + loadErrors []error + loadCalls int +} + +func (b *fakePeopleInferenceControlBackend) LoadPeopleInferenceStatus(context.Context) (PeopleInferenceStatus, error) { + index := b.loadCalls + b.loadCalls++ + if index < len(b.loadErrors) && b.loadErrors[index] != nil { + return PeopleInferenceStatus{}, b.loadErrors[index] + } + return b.status, nil +} + +func (b *fakePeopleInferenceControlBackend) DisablePeopleInference(_ context.Context, fingerprint string) (PeopleInferenceStatus, error) { + b.disabledFingerprint = fingerprint + if b.disableErr != nil { + return PeopleInferenceStatus{}, b.disableErr + } + b.status.ConfiguredEnabled = false + b.status.PendingRestart = true + return b.status, nil +} + +func (b *fakePeopleInferenceControlBackend) RevokePeopleInferenceConsent(_ context.Context, profile, fingerprint string) (PeopleInferenceStatus, error) { + b.revokedProfile, b.revokedFingerprint = profile, fingerprint + return b.status, nil +} + +func (b *fakePeopleInferenceControlBackend) RemovePeopleInferenceProfile(_ context.Context, profile, fingerprint string) (PeopleInferenceStatus, error) { + b.removedProfile, b.removedFingerprint = profile, fingerprint + b.status.Configured = "backup" + b.status.ConfiguredFingerprint = "fp-backup" + b.status.PendingRestart = true + return b.status, nil +} diff --git a/internal/tui/settings_people_inference.go b/internal/tui/settings_people_inference.go new file mode 100644 index 000000000..9abbda7bf --- /dev/null +++ b/internal/tui/settings_people_inference.go @@ -0,0 +1,630 @@ +package tui + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + "time" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "go.kenn.io/msgvault/internal/textutil" +) + +// PeopleInferenceBackend performs enrollment on the daemon. The TUI never +// stores credentials or reproduces the daemon's check and consent rules. +type PeopleInferenceBackend interface { + StartCodexLogin(ctx context.Context, profile string) (CodexDeviceLogin, error) + PollCodexLogin(ctx context.Context, sessionID string) (CodexLoginPoll, error) + CancelCodexLogin(ctx context.Context, sessionID string) error + ListCodexModels(ctx context.Context, sessionID string) ([]CodexModelChoice, error) + SaveCodexProfile(ctx context.Context, sessionID string, profile CodexProfileRequest) (string, error) + CheckCodexProfile(ctx context.Context, profile string) (PeopleInferenceDisclosure, error) + ConsentCodexProfile(ctx context.Context, profile, fingerprint string) error + SelectCodexProfile(ctx context.Context, profile string) error + LoadPeopleInferenceStatus(ctx context.Context) (PeopleInferenceStatus, error) +} + +type CodexDeviceLogin struct { + SessionID string + URL string + Code string + Deadline time.Time +} + +type CodexLoginPoll struct { + Complete bool + Failed bool +} + +type CodexModelChoice struct { + ID string + DefaultReasoningEffort string + ReasoningEfforts []string +} + +type CodexProfileRequest struct { + Name string + Model string + ReasoningEffort string + RetentionPosture string + TrainingPosture string + AllowedSources []string + SourceSince string + SourceUntil string + AllowSensitive bool +} + +type PeopleInferenceDisclosure struct { + Profile string + Fingerprint string + Text string +} + +type PeopleInferenceStatus struct { + Configured string + ConfiguredFingerprint string + ConfiguredEnabled bool + Running string + RunningFingerprint string + RunningEnabled bool + PendingRestart bool +} + +type codexSettingsState struct { + active bool + requestID uint64 + stage string + policy CodexProfileRequest + sensitiveSet bool + editing string + editor textinput.Model + login CodexDeviceLogin + models []CodexModelChoice + modelCursor int + effortCursor int + profile string + disclosure PeopleInferenceDisclosure + consented bool + status PeopleInferenceStatus + message string + ctx context.Context + cancel context.CancelFunc +} + +type codexLoginStartedMsg struct { + login CodexDeviceLogin + err error + requestID uint64 +} +type codexLoginPolledMsg struct { + poll CodexLoginPoll + err error + requestID uint64 +} +type codexModelsLoadedMsg struct { + models []CodexModelChoice + err error + requestID uint64 +} +type codexProfileSavedMsg struct { + profile string + err error + requestID uint64 +} +type codexCheckedMsg struct { + disclosure PeopleInferenceDisclosure + err error + requestID uint64 +} +type codexConsentedMsg struct { + err error + requestID uint64 +} +type codexSelectedMsg struct { + status PeopleInferenceStatus + err error + requestID uint64 +} +type codexStatusLoadedMsg struct { + status PeopleInferenceStatus + err error + requestID uint64 +} +type codexPollTickMsg struct{ requestID uint64 } + +func (m Model) peopleInferenceBackend() PeopleInferenceBackend { + backend, _ := m.settingsBackend.(PeopleInferenceBackend) + return backend +} + +func (m Model) openCodexSettings() (tea.Model, tea.Cmd) { + backend := m.peopleInferenceBackend() + if backend == nil { + return m, nil + } + m.settingsRequestID++ + id := m.settingsRequestID + ctx, cancel := context.WithCancel(context.Background()) + m.settings.codex = codexSettingsState{active: true, stage: "setup", requestID: id, ctx: ctx, cancel: cancel} + return m, nil +} + +func (m Model) closeCodexSettings() (Model, tea.Cmd) { + s := m.settings.codex + if s.cancel != nil { + s.cancel() + } + if s.stage == "selected" { + status := s.status + m.settings.peopleInferenceStatus = &status + } + m.settings.codex = codexSettingsState{} + if s.login.SessionID == "" { + return m, nil + } + backend := m.peopleInferenceBackend() + return m, func() tea.Msg { + _ = backend.CancelCodexLogin(context.Background(), s.login.SessionID) + return nil + } +} + +func (m Model) handleCodexSettingsKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + s := &m.settings.codex + if s.stage == "setup" && s.editing != "" { + return m.handleCodexPolicyEdit(msg) + } + if s.stage == "saving" && (msg.String() == keyNameEsc || msg.String() == keyNameCtrlC) { + s.message = "Profile save in progress; wait for the result." + return m, nil + } + switch msg.String() { + case keyNameEsc: + return m.closeCodexSettings() + case keyNameCtrlC: + updated, cancel := m.closeCodexSettings() + m = updated + m.quitting = true + if cancel == nil { + return m, tea.Quit + } + return m, func() tea.Msg { _ = cancel(); return tea.Quit() } + } + backend := m.peopleInferenceBackend() + id := s.requestID + session := s.login.SessionID + ctx := s.ctx + if s.stage == "setup" { + switch msg.String() { + case "1", "2", "3", "4", "5": + return m.beginCodexPolicyEdit(msg.String()) + case "c", "m", "d": + source := map[string]string{"c": "conversation_text", "m": "meeting_text", "d": "document_text"}[msg.String()] + if slices.Contains(s.policy.AllowedSources, source) { + s.policy.AllowedSources = slices.DeleteFunc(s.policy.AllowedSources, func(v string) bool { return v == source }) + } else { + s.policy.AllowedSources = append(s.policy.AllowedSources, source) + } + return m, nil + case "y", "n": + s.sensitiveSet = true + s.policy.AllowSensitive = msg.String() == "y" + return m, nil + case keyNameEnter: + if err := s.validatePolicy(); err != nil { + s.message = err.Error() + return m, nil + } + s.stage, s.message = "starting", "" + name := s.policy.Name + return m, func() tea.Msg { + login, err := backend.StartCodexLogin(ctx, name) + return codexLoginStartedMsg{login: login, err: err, requestID: id} + } + } + return m, nil + } + switch msg.String() { + case "r": + if s.stage == "waiting" && session != "" { + return m, m.pollCodexLogin(id, session) + } + return m, func() tea.Msg { + status, err := backend.LoadPeopleInferenceStatus(ctx) + return codexStatusLoadedMsg{status: status, err: err, requestID: id} + } + case "m": + if s.stage == "authenticated" || (s.stage == "models" && len(s.models) == 0) { + return m, func() tea.Msg { + models, err := backend.ListCodexModels(ctx, s.login.SessionID) + return codexModelsLoadedMsg{models: models, err: err, requestID: id} + } + } + case "up", "k": + if s.stage == "models" && s.modelCursor > 0 { + s.modelCursor-- + s.selectDefaultEffort() + } + case "down", "j": + if s.stage == "models" && s.modelCursor+1 < len(s.models) { + s.modelCursor++ + s.selectDefaultEffort() + } + case "e": + if s.stage == "models" && len(s.models) > 0 && len(s.models[s.modelCursor].ReasoningEfforts) > 0 { + s.effortCursor = (s.effortCursor + 1) % len(s.models[s.modelCursor].ReasoningEfforts) + } + case keyNameEnter: + if s.stage == "models" && len(s.models) > 0 { + model := s.models[s.modelCursor] + if len(model.ReasoningEfforts) == 0 { + s.message = "Selected model has no supported reasoning effort." + return m, nil + } + request := s.policy + request.Model = model.ID + request.ReasoningEffort = model.ReasoningEfforts[s.effortCursor] + s.stage, s.message = "saving", "" + return m, func() tea.Msg { + profile, err := backend.SaveCodexProfile(ctx, s.login.SessionID, request) + return codexProfileSavedMsg{profile: profile, err: err, requestID: id} + } + } + case "c": + if s.stage == "profile" && s.profile != "" { + return m, func() tea.Msg { + disclosure, err := backend.CheckCodexProfile(ctx, s.profile) + return codexCheckedMsg{disclosure: disclosure, err: err, requestID: id} + } + } + case "a": + if s.stage == "checked" && s.disclosure.Fingerprint != "" { + profile, fingerprint := s.disclosure.Profile, s.disclosure.Fingerprint + return m, func() tea.Msg { + err := backend.ConsentCodexProfile(ctx, profile, fingerprint) + return codexConsentedMsg{err: err, requestID: id} + } + } + case "s": + if !s.consented { + if s.disclosure.Fingerprint == "" { + s.message = "Run a synthetic check before selecting." + } else { + s.message = "Grant consent before selecting." + } + return m, nil + } + profile := s.disclosure.Profile + return m, func() tea.Msg { + if err := backend.SelectCodexProfile(ctx, profile); err != nil { + return codexSelectedMsg{err: err, requestID: id} + } + status, err := backend.LoadPeopleInferenceStatus(ctx) + return codexSelectedMsg{status: status, err: err, requestID: id} + } + } + return m, nil +} + +func (s *codexSettingsState) selectDefaultEffort() { + s.effortCursor = 0 + if s.modelCursor >= len(s.models) { + return + } + model := s.models[s.modelCursor] + for i, effort := range model.ReasoningEfforts { + if effort == model.DefaultReasoningEffort { + s.effortCursor = i + return + } + } +} + +func (m Model) beginCodexPolicyEdit(field string) (tea.Model, tea.Cmd) { + s := &m.settings.codex + input := textinput.New() + input.CharLimit = 512 + input.SetWidth(max(min(m.width-12, 64), 12)) + switch field { + case "1": + input.SetValue(s.policy.Name) + case "2": + input.SetValue(s.policy.SourceSince) + case "3": + input.SetValue(s.policy.SourceUntil) + case "4": + input.SetValue(s.policy.RetentionPosture) + case "5": + input.SetValue(s.policy.TrainingPosture) + } + s.editing, s.editor, s.message = field, input, "" + return m, s.editor.Focus() +} + +func (m Model) handleCodexPolicyEdit(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + s := &m.settings.codex + switch msg.String() { + case keyNameCtrlC: + updated, cmd := m.closeCodexSettings() + m = updated + m.quitting = true + if cmd != nil { + return m, func() tea.Msg { _ = cmd(); return tea.Quit() } + } + return m, tea.Quit + case keyNameEsc: + s.editor.Blur() + s.editing = "" + return m, nil + case keyNameEnter: + value := strings.TrimSpace(s.editor.Value()) + switch s.editing { + case "1": + s.policy.Name = value + case "2": + s.policy.SourceSince = value + case "3": + s.policy.SourceUntil = value + case "4": + s.policy.RetentionPosture = value + case "5": + s.policy.TrainingPosture = value + } + s.editor.Blur() + s.editing = "" + return m, nil + } + var cmd tea.Cmd + s.editor, cmd = s.editor.Update(msg) + return m, cmd +} + +func (s *codexSettingsState) validatePolicy() error { + if s.policy.Name == "" || len(s.policy.AllowedSources) == 0 || !s.sensitiveSet || + s.policy.RetentionPosture == "" || s.policy.TrainingPosture == "" { + return errors.New("complete the profile policy before starting Codex sign-in") + } + since, err := time.Parse("2006-01-02", s.policy.SourceSince) + if err != nil { + return errors.New("enter a valid source since date (YYYY-MM-DD)") + } + if s.policy.SourceUntil != "" { + until, err := time.Parse("2006-01-02", s.policy.SourceUntil) + if err != nil || until.Before(since) { + return errors.New("enter a valid source until date on or after source since") + } + } + return nil +} + +func (m Model) pollCodexLogin(id uint64, session string) tea.Cmd { + backend := m.peopleInferenceBackend() + ctx := m.settings.codex.ctx + return func() tea.Msg { + poll, err := backend.PollCodexLogin(ctx, session) + return codexLoginPolledMsg{poll: poll, err: err, requestID: id} + } +} + +func (m Model) handleCodexSettingsMsg(msg tea.Msg) (tea.Model, tea.Cmd) { + s := &m.settings.codex + requestID := uint64(0) + switch v := msg.(type) { + case codexLoginStartedMsg: + requestID = v.requestID + if !s.active || requestID != s.requestID { + if v.login.SessionID != "" { + backend := m.peopleInferenceBackend() + return m, func() tea.Msg { _ = backend.CancelCodexLogin(context.Background(), v.login.SessionID); return nil } + } + return m, nil + } + if v.err != nil { + s.stage, s.message = "setup", v.err.Error() + return m, nil + } + s.login, s.stage = v.login, "waiting" + return m, codexPollTick(s.requestID) + case codexPollTickMsg: + if s.active && s.stage == "waiting" && v.requestID == s.requestID { + return m, m.pollCodexLogin(s.requestID, s.login.SessionID) + } + return m, nil + case codexLoginPolledMsg: + requestID = v.requestID + if !s.active || requestID != s.requestID { + return m, nil + } + if v.err != nil { + s.message = v.err.Error() + if v.poll.Failed { + s.stage = "error" + return m, nil + } + return m, codexPollTick(s.requestID) + } + s.message = "" + if v.poll.Complete { + s.stage = "authenticated" + backend := m.peopleInferenceBackend() + session := s.login.SessionID + ctx := s.ctx + return m, func() tea.Msg { + models, err := backend.ListCodexModels(ctx, session) + return codexModelsLoadedMsg{models: models, err: err, requestID: requestID} + } + } + return m, codexPollTick(s.requestID) + case codexModelsLoadedMsg: + requestID = v.requestID + if !s.active || requestID != s.requestID { + return m, nil + } + if v.err != nil { + s.message = v.err.Error() + return m, nil + } + s.models, s.stage = v.models, "models" + s.modelCursor = 0 + s.selectDefaultEffort() + s.message = "" + case codexProfileSavedMsg: + requestID = v.requestID + if !s.active || requestID != s.requestID { + return m, nil + } + if v.err != nil { + s.stage = "models" + s.message = v.err.Error() + return m, nil + } + s.profile = v.profile + s.stage = "profile" + s.login.SessionID = "" + s.message = "" + case codexCheckedMsg: + requestID = v.requestID + if !s.active || requestID != s.requestID { + return m, nil + } + if v.err != nil { + s.recordMutationError(v.err) + return m, nil + } + s.disclosure, s.consented, s.stage = v.disclosure, false, "checked" + s.message = "" + case codexConsentedMsg: + requestID = v.requestID + if !s.active || requestID != s.requestID { + return m, nil + } + if v.err != nil { + s.recordMutationError(v.err) + return m, nil + } + s.consented, s.stage = true, "consented" + s.message = "" + case codexSelectedMsg: + requestID = v.requestID + if !s.active || requestID != s.requestID { + return m, nil + } + if v.err != nil { + s.recordMutationError(v.err) + return m, nil + } + s.status, s.stage = v.status, "selected" + s.message = "" + case codexStatusLoadedMsg: + requestID = v.requestID + if !s.active || requestID != s.requestID { + return m, nil + } + if v.err != nil { + s.message = v.err.Error() + return m, nil + } + s.status = v.status + } + return m, nil +} + +func (s *codexSettingsState) recordMutationError(err error) { + var conflict *SettingsConflictError + if errors.As(err, &conflict) && conflict.Scope == SettingsConflictConfig { + s.disclosure = PeopleInferenceDisclosure{} + s.consented = false + s.stage = "profile" + s.message = "Configuration changed; reload settings and run synthetic check." + return + } + s.message = err.Error() +} + +func codexPollTick(id uint64) tea.Cmd { + return tea.Tick(time.Second, func(time.Time) tea.Msg { return codexPollTickMsg{requestID: id} }) +} + +func (m Model) renderCodexSettings() string { + s := m.settings.codex + lines := []string{m.styles.titleBar.Render("People inference Ā· Codex"), ""} + switch s.stage { + case "setup": + lines = append(lines, + "Codex profile setup", + "[1] Profile name: "+textutil.SanitizeTerminal(s.policy.Name), + "Sources: [c] conversation [m] meetings [d] documents", + "Selected: "+textutil.SanitizeTerminal(strings.Join(s.policy.AllowedSources, ", ")), + "[2] Since: "+textutil.SanitizeTerminal(s.policy.SourceSince), + "[3] Until (optional): "+textutil.SanitizeTerminal(s.policy.SourceUntil), + "Sensitive content: [y] allow [n] exclude", + "[4] Retention statement: "+textutil.SanitizeTerminal(s.policy.RetentionPosture), + "[5] Training statement: "+textutil.SanitizeTerminal(s.policy.TrainingPosture), + ) + if s.editing != "" { + lines = append(lines, "Edit field "+s.editing+": "+s.editor.View()) + } + lines = append(lines, "[Enter] Sign in with Codex") + case "starting": + lines = append(lines, "Starting Codex sign-in…") + case "waiting": + lines = append(lines, "Open: "+textutil.SanitizeTerminal(s.login.URL), "Code: "+textutil.SanitizeTerminal(s.login.Code)) + if !s.login.Deadline.IsZero() { + lines = append(lines, "Expires: "+s.login.Deadline.UTC().Format("15:04 UTC")) + } + lines = append(lines, "Waiting for sign-in… [r] Check now") + case "authenticated": + lines = append(lines, "Signed in. [m] List available models") + case "models": + if len(s.models) == 0 { + lines = append(lines, "No Codex models available. [m] Retry") + break + } + lines = append(lines, "Choose a model:") + for i, model := range s.models { + prefix := " " + if i == s.modelCursor { + prefix = "ā–¶ " + } + lines = append(lines, prefix+textutil.SanitizeTerminal(model.ID)) + if i == s.modelCursor && len(model.ReasoningEfforts) > 0 { + lines = append(lines, "Reasoning: "+textutil.SanitizeTerminal(model.ReasoningEfforts[s.effortCursor])+" [e] Change") + } + } + lines = append(lines, "[j/k] Move [Enter] Use model and reasoning") + case "saving": + lines = append(lines, "Saving Codex profile…") + case "profile": + lines = append(lines, "Profile saved for "+textutil.SanitizeTerminal(s.profile), "[c] Run synthetic check") + case "checked", "consented": + lines = append(lines, "Synthetic check passed.", textutil.SanitizeTerminalMultiline(s.disclosure.Text)) + if s.consented { + lines = append(lines, "Consent granted. [s] Select profile") + } else { + lines = append(lines, "[a] Grant consent") + } + case "selected": + lines = append(lines, "Selected profile: "+textutil.SanitizeTerminal(s.status.Configured)) + case "error": + lines = append(lines, "Sign-in failed") + } + if s.status.Configured != "" || s.status.Running != "" { + lines = append(lines, fmt.Sprintf("Configured: %s Running: %s", textutil.SanitizeTerminal(s.status.Configured), textutil.SanitizeTerminal(s.status.Running))) + if s.status.PendingRestart { + lines = append(lines, "Restart the daemon to use the selected profile.") + } + } + if s.message != "" { + lines = append(lines, textutil.SanitizeTerminalMultiline(s.message)) + } + lines = append(lines, "[r] Refresh [Esc] Cancel and return") + width := max(m.width, 20) + wrapped := []string{lines[0]} + for _, line := range lines[1:] { + wrapped = append(wrapped, wrapText(line, width)...) + } + return strings.Join(wrapped, "\n") +} diff --git a/internal/tui/settings_people_inference_test.go b/internal/tui/settings_people_inference_test.go new file mode 100644 index 000000000..dffabdfe0 --- /dev/null +++ b/internal/tui/settings_people_inference_test.go @@ -0,0 +1,454 @@ +package tui + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSettingsCodexConfigConflictRequiresFreshCheck(t *testing.T) { + assert := assert.New(t) + model := loadedSettingsModelWithBackend(t, &fakeSettingsBackend{loads: []SettingsSnapshot{settingsFixture()}}) + model.settings.codex = codexSettingsState{ + active: true, requestID: 42, stage: "checked", profile: "codex-profile", + disclosure: PeopleInferenceDisclosure{Profile: "codex-profile", Fingerprint: "fp-old", Text: "Old disclosure"}, + } + model = sendSettingsMsg(t, model, codexConsentedMsg{ + requestID: 42, + err: &SettingsConflictError{Scope: SettingsConflictConfig, Err: errors.New("HTTP 412")}, + }) + assert.Equal("profile", model.settings.codex.stage) + assert.Empty(model.settings.codex.disclosure.Fingerprint) + assert.False(model.settings.codex.consented) + assert.Contains(stripANSI(model.renderView()), "Run synthetic check") +} + +func TestSettingsCodexCollectsPolicyBeforeStartingDeviceLogin(t *testing.T) { + require := require.New(t) + + assert := assert.New(t) + backend := &fakePeopleInferenceBackend{loads: []SettingsSnapshot{settingsFixture()}} + model := loadedSettingsModelWithBackend(t, &backend.fakeSettingsBackend) + model.settingsBackend = backend + assert.Contains(stripANSI(model.renderView()), "[p] People inference") + model, start := sendKey(t, model, key('p')) + assert.Nil(start) + assert.Contains(stripANSI(model.renderView()), "Codex profile setup") + model, start = sendKey(t, model, keyEnter()) + assert.Nil(start) + assert.Contains(stripANSI(model.renderView()), "complete the profile policy") + + model = setCodexSetupField(t, model, '1', "codex-custom") + model, _ = sendKey(t, model, key('c')) + model = setCodexSetupField(t, model, '2', "2025-01-01") + model = setCodexSetupField(t, model, '3', "2025-12-31") + model, _ = sendKey(t, model, key('n')) + model = setCodexSetupField(t, model, '4', "operator assertion: no retention") + model = setCodexSetupField(t, model, '5', "operator assertion: no training") + model, start = sendKey(t, model, keyEnter()) + require.NotNil(start) + assert.Contains(stripANSI(model.renderView()), "Starting Codex sign-in") + model = sendSettingsMsg(t, model, start()) + assert.Equal("codex-custom", backend.startedProfile) + assert.Contains(stripANSI(model.renderView()), "Waiting for sign-in") +} + +func setCodexSetupField(t *testing.T, model Model, shortcut rune, value string) Model { + t.Helper() + model, _ = sendKey(t, model, key(shortcut)) + for _, r := range value { + model, _ = sendKey(t, model, key(r)) + } + model, _ = sendKey(t, model, keyEnter()) + return model +} + +func startConfiguredCodex(t *testing.T, model Model) (Model, tea.Cmd) { + t.Helper() + require := require.New(t) + model, opened := sendKey(t, model, key('p')) + require.Nil(opened) + model = setCodexSetupField(t, model, '1', "codex-profile") + model, _ = sendKey(t, model, key('c')) + model = setCodexSetupField(t, model, '2', "2025-01-01") + model, _ = sendKey(t, model, key('n')) + model = setCodexSetupField(t, model, '4', "operator assertion: no retention") + model = setCodexSetupField(t, model, '5', "operator assertion: no training") + return sendKey(t, model, keyEnter()) +} + +// These tests catch a login command blocking the event loop, a stale login +// response reviving a cancelled draft, and selection before check and consent. +func TestSettingsCodexJourneyRequiresCheckAndConsent(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + backend := &fakePeopleInferenceBackend{ + loads: []SettingsSnapshot{settingsFixture()}, + login: CodexDeviceLogin{SessionID: "session-1", URL: "https://example.test/device", Code: "ABCD-EFGH", Deadline: time.Date(2026, 9, 23, 12, 5, 0, 0, time.UTC)}, + models: []CodexModelChoice{ + {ID: "codex-model-a", DefaultReasoningEffort: "medium", ReasoningEfforts: []string{"low", "medium"}}, + {ID: "codex-model-b", DefaultReasoningEffort: "medium", ReasoningEfforts: []string{"medium", "high"}}, + }, + check: PeopleInferenceDisclosure{Profile: "codex-profile", Fingerprint: "fp-1", Text: "Conversation text since 2025-01-01 may be sent to Codex. Sensitive content: no."}, + status: PeopleInferenceStatus{Configured: "codex-profile", Running: "previous-profile", PendingRestart: true}, + } + model := loadedSettingsModelWithBackend(t, &backend.fakeSettingsBackend) + model.settingsBackend = backend + + model, start := startConfiguredCodex(t, model) + require.NotNil(start) + assert.Contains(model.renderView(), "Starting Codex sign-in") + model = sendSettingsMsg(t, model, start()) + view := stripANSI(model.renderView()) + assert.Contains(view, "https://example.test/device") + assert.Contains(view, "ABCD-EFGH") + assert.Contains(view, "12:05 UTC") + + model, poll := sendKey(t, model, key('r')) + require.NotNil(poll) + backend.poll = CodexLoginPoll{Complete: true} + updated, models := model.Update(poll()) + model = asModel(t, updated) + require.NotNil(models) + model = sendSettingsMsg(t, model, models()) + assert.Contains(stripANSI(model.renderView()), "codex-model-a") + model, _ = sendKey(t, model, key('j')) + model, _ = sendKey(t, model, key('e')) + assert.Contains(stripANSI(model.renderView()), "high") + model, save := sendKey(t, model, keyEnter()) + require.NotNil(save) + model = sendSettingsMsg(t, model, save()) + assert.Equal("session-1", backend.savedDraft) + assert.Equal(CodexProfileRequest{ + Name: "codex-profile", Model: "codex-model-b", ReasoningEffort: "high", + AllowedSources: []string{"conversation_text"}, SourceSince: "2025-01-01", AllowSensitive: false, + RetentionPosture: "operator assertion: no retention", TrainingPosture: "operator assertion: no training", + }, backend.savedRequest) + + model, selectBeforeCheck := sendKey(t, model, key('s')) + assert.Nil(selectBeforeCheck) + assert.Contains(stripANSI(model.renderView()), "synthetic check") + model, check := sendKey(t, model, key('c')) + require.NotNil(check) + model = sendSettingsMsg(t, model, check()) + assert.Equal("codex-profile", backend.checkedProfile) + assert.Contains(stripANSI(model.renderView()), backend.check.Text) + model, selectBeforeConsent := sendKey(t, model, key('s')) + assert.Nil(selectBeforeConsent) + assert.Contains(stripANSI(model.renderView()), "consent") + model, consent := sendKey(t, model, key('a')) + require.NotNil(consent) + model = sendSettingsMsg(t, model, consent()) + assert.Equal("codex-profile", backend.consentedProfile) + assert.Equal("fp-1", backend.consentedFingerprint) + model, selectProfile := sendKey(t, model, key('s')) + require.NotNil(selectProfile) + model = sendSettingsMsg(t, model, selectProfile()) + assert.Equal("codex-profile", backend.selectedProfile) + assert.Contains(stripANSI(model.renderView()), "previous-profile") + assert.Contains(stripANSI(model.renderView()), "Restart") + model, refresh := sendKey(t, model, key('r')) + require.NotNil(refresh) + model = sendSettingsMsg(t, model, refresh()) + view = stripANSI(model.renderView()) + assert.Contains(view, "codex-profile") + assert.Contains(view, "previous-profile") + assert.Contains(view, "Restart") + assert.NotContains(view, "Grant consent before selecting") + model, cancel := sendKey(t, model, keyEsc()) + if cancel != nil { + _ = cancel() + } + view = stripANSI(model.renderView()) + assert.Contains(view, "People inference: codex-profile") + assert.Contains(view, "Running: previous-profile") + assert.Contains(view, "Restart") +} + +func TestSettingsCodexLoginWaitDoesNotBlockEscape(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + backend := &fakePeopleInferenceBackend{ + loads: []SettingsSnapshot{settingsFixture()}, + started: make(chan struct{}), + startExited: make(chan struct{}), + } + model := loadedSettingsModelWithBackend(t, &backend.fakeSettingsBackend) + model.settingsBackend = backend + model, start := startConfiguredCodex(t, model) + require.NotNil(start) + go func() { _ = start() }() + select { + case <-backend.started: + case <-time.After(3 * time.Second): + require.FailNow("login command did not start") + } + model, cancel := sendKey(t, model, keyEsc()) + assert.Nil(cancel) + assert.False(model.settings.codex.active) + select { + case <-backend.startExited: + case <-time.After(3 * time.Second): + require.FailNow("login command did not stop after Escape") + } +} + +func TestSettingsCodexEmptyModelListCanRetry(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + backend := &fakePeopleInferenceBackend{ + loads: []SettingsSnapshot{settingsFixture()}, + login: CodexDeviceLogin{SessionID: "session-empty"}, + poll: CodexLoginPoll{Complete: true}, + } + model := loadedSettingsModelWithBackend(t, &backend.fakeSettingsBackend) + model.settingsBackend = backend + model, start := startConfiguredCodex(t, model) + model = sendSettingsMsg(t, model, start()) + model, poll := sendKey(t, model, key('r')) + updated, models := model.Update(poll()) + model = sendSettingsMsg(t, asModel(t, updated), models()) + assert.Contains(stripANSI(model.renderView()), "No Codex models available") + model, retry := sendKey(t, model, key('m')) + require.NotNil(retry) + backend.models = []CodexModelChoice{{ID: "codex-model-a", DefaultReasoningEffort: "medium", ReasoningEfforts: []string{"medium"}}} + model = sendSettingsMsg(t, model, retry()) + assert.Contains(stripANSI(model.renderView()), "codex-model-a") +} + +func TestSettingsCodexPollFailureCanRetryPendingLogin(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + backend := &fakePeopleInferenceBackend{ + loads: []SettingsSnapshot{settingsFixture()}, + login: CodexDeviceLogin{SessionID: "session-retry", + URL: "https://example.test/device", Code: "RETRY-CODE"}, + models: []CodexModelChoice{{ID: "codex-model-a", DefaultReasoningEffort: "medium", ReasoningEfforts: []string{"medium"}}}, + } + model := loadedSettingsModelWithBackend(t, &backend.fakeSettingsBackend) + model.settingsBackend = backend + model, start := startConfiguredCodex(t, model) + require.NotNil(start) + model = sendSettingsMsg(t, model, start()) + + backend.pollErr = errors.New("temporary gateway timeout") + model, poll := sendKey(t, model, key('r')) + require.NotNil(poll) + updated, nextPoll := model.Update(poll()) + model = asModel(t, updated) + require.NotNil(nextPoll) + view := stripANSI(model.renderView()) + require.Contains(view, "https://example.test/device") + assert.Contains(view, "RETRY-CODE") + assert.Contains(view, "temporary gateway timeout") + assert.Contains(view, "[r] Check now") + + backend.pollErr = nil + model, retry := sendKey(t, model, key('r')) + require.NotNil(retry) + updated, nextPoll = model.Update(retry()) + model = asModel(t, updated) + assert.Equal("session-retry", backend.polledSession) + require.NotNil(nextPoll) + view = stripANSI(model.renderView()) + assert.Contains(view, "RETRY-CODE") + assert.NotContains(view, "temporary gateway timeout") + + backend.poll = CodexLoginPoll{Complete: true} + model, retry = sendKey(t, model, key('r')) + require.NotNil(retry) + updated, models := model.Update(retry()) + model = asModel(t, updated) + require.NotNil(models) + model = sendSettingsMsg(t, model, models()) + assert.Contains(stripANSI(model.renderView()), "codex-model-a") +} + +func TestSettingsCodexEscapeCancelsInFlightPoll(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + backend := &fakePeopleInferenceBackend{ + loads: []SettingsSnapshot{settingsFixture()}, + login: CodexDeviceLogin{SessionID: "session-poll"}, + pollStarted: make(chan struct{}), + pollExited: make(chan struct{}), + } + model := loadedSettingsModelWithBackend(t, &backend.fakeSettingsBackend) + model.settingsBackend = backend + model, start := startConfiguredCodex(t, model) + model = sendSettingsMsg(t, model, start()) + model, poll := sendKey(t, model, key('r')) + require.NotNil(poll) + go func() { _ = poll() }() + select { + case <-backend.pollStarted: + case <-time.After(3 * time.Second): + require.FailNow("poll command did not start") + } + model, cancel := sendKey(t, model, keyEsc()) + require.NotNil(cancel) + _ = cancel() + select { + case <-backend.pollExited: + case <-time.After(3 * time.Second): + require.FailNow("poll command did not stop after Escape") + } + assert.False(model.settings.codex.active) +} + +func TestSettingsCodexEscapeCancelsAndIgnoresLateLogin(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + backend := &fakePeopleInferenceBackend{loads: []SettingsSnapshot{settingsFixture()}, login: CodexDeviceLogin{SessionID: "session-2"}} + model := loadedSettingsModelWithBackend(t, &backend.fakeSettingsBackend) + model.settingsBackend = backend + model, start := startConfiguredCodex(t, model) + model = sendSettingsMsg(t, model, start()) + model, cancel := sendKey(t, model, keyEsc()) + require.NotNil(cancel) + assert.NotContains(stripANSI(model.renderView()), "session-2") + _ = cancel() + assert.Equal("session-2", backend.cancelled) + model = sendSettingsMsg(t, model, codexLoginStartedMsg{login: backend.login, requestID: 1}) + assert.NotContains(stripANSI(model.renderView()), "Starting Codex sign-in") + updated, lateCancel := model.Update(codexLoginStartedMsg{login: CodexDeviceLogin{SessionID: "late-session"}, requestID: 1}) + asModel(t, updated) + require.NotNil(lateCancel) + _ = lateCancel() + assert.Equal("late-session", backend.cancelled) +} + +func TestSettingsCodexNarrowViewAndCtrlCCancel(t *testing.T) { + require := require.New(t) + + assert := assert.New(t) + longURL := "https://example.test/device/" + strings.Repeat("a", 60) + backend := &fakePeopleInferenceBackend{loads: []SettingsSnapshot{settingsFixture()}, login: CodexDeviceLogin{SessionID: "session-3", URL: longURL, Code: "ABCD-EFGH"}} + model := loadedSettingsModelWithBackend(t, &backend.fakeSettingsBackend) + model.settingsBackend = backend + model = resizeModel(t, model, 42, 20) + assert.Contains(stripANSI(model.renderView()), "People inference") + model, start := startConfiguredCodex(t, model) + model = sendSettingsMsg(t, model, start()) + view := stripANSI(model.renderView()) + assert.Contains(strings.ReplaceAll(view, "\n", ""), longURL) + assert.Contains(view, "ABCD-EFGH") + _, cancel := sendKey(t, model, tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + require.NotNil(cancel) + _ = cancel() + assert.Equal("session-3", backend.cancelled) + assert.False(backend.selected) +} + +func TestSettingsHidesPeopleInferenceUntilBackendIsWired(t *testing.T) { + assert := assert.New(t) + + model := loadedSettingsModel(t, settingsFixture()) + assert.NotContains(stripANSI(model.renderView()), "People inference") + model, command := sendKey(t, model, key('p')) + assert.Nil(command) + assert.NotContains(stripANSI(model.renderView()), "People inference") +} + +type fakePeopleInferenceBackend struct { + fakeSettingsBackend + + login CodexDeviceLogin + poll CodexLoginPoll + pollErr error + polledSession string + models []CodexModelChoice + check PeopleInferenceDisclosure + status PeopleInferenceStatus + cancelled string + selected bool + savedDraft string + savedRequest CodexProfileRequest + checkedProfile string + consentedProfile string + consentedFingerprint string + selectedProfile string + startedProfile string + started chan struct{} + startExited chan struct{} + pollStarted chan struct{} + pollExited chan struct{} +} + +func (b *fakePeopleInferenceBackend) StartCodexLogin(ctx context.Context, name string) (CodexDeviceLogin, error) { + b.startedProfile = name + if b.started != nil { + close(b.started) + <-ctx.Done() + close(b.startExited) + return CodexDeviceLogin{}, ctx.Err() + } + return b.login, nil +} +func (b *fakePeopleInferenceBackend) PollCodexLogin(ctx context.Context, session string) (CodexLoginPoll, error) { + b.polledSession = session + if b.pollStarted != nil { + close(b.pollStarted) + <-ctx.Done() + close(b.pollExited) + return CodexLoginPoll{}, ctx.Err() + } + return b.poll, b.pollErr +} +func (b *fakePeopleInferenceBackend) CancelCodexLogin(_ context.Context, session string) error { + b.cancelled = session + return nil +} +func (b *fakePeopleInferenceBackend) ListCodexModels(context.Context, string) ([]CodexModelChoice, error) { + return b.models, nil +} +func (b *fakePeopleInferenceBackend) SaveCodexProfile(_ context.Context, draft string, request CodexProfileRequest) (string, error) { + b.savedDraft, b.savedRequest = draft, request + return "codex-profile", nil +} +func (b *fakePeopleInferenceBackend) CheckCodexProfile(_ context.Context, profile string) (PeopleInferenceDisclosure, error) { + b.checkedProfile = profile + return b.check, nil +} +func (b *fakePeopleInferenceBackend) ConsentCodexProfile(_ context.Context, profile, fingerprint string) error { + b.consentedProfile, b.consentedFingerprint = profile, fingerprint + return nil +} +func (b *fakePeopleInferenceBackend) SelectCodexProfile(_ context.Context, profile string) error { + b.selected = true + b.selectedProfile = profile + return nil +} +func (b *fakePeopleInferenceBackend) LoadPeopleInferenceStatus(context.Context) (PeopleInferenceStatus, error) { + return b.status, nil +} + +func TestSettingsCodexTerminalFailureStopsPolling(t *testing.T) { + assert := assert.New(t) + backend := &fakePeopleInferenceBackend{ + loads: []SettingsSnapshot{settingsFixture()}, + login: CodexDeviceLogin{SessionID: "session-1"}, + poll: CodexLoginPoll{Failed: true}, + pollErr: errors.New("codex device login was cancelled"), + } + model := loadedSettingsModelWithBackend(t, &backend.fakeSettingsBackend) + model.settingsBackend = backend + model, start := startConfiguredCodex(t, model) + model = sendSettingsMsg(t, model, start()) + model, poll := sendKey(t, model, key('r')) + updated, next := model.Update(poll()) + model = asModel(t, updated) + assert.Nil(next) + assert.Contains(stripANSI(model.renderView()), "Sign-in failed") + assert.Contains(stripANSI(model.renderView()), "was cancelled") + assert.NotContains(stripANSI(model.renderView()), "Waiting for sign-in") +} diff --git a/internal/tui/settings_state.go b/internal/tui/settings_state.go index 00be8c51e..5560ecc99 100644 --- a/internal/tui/settings_state.go +++ b/internal/tui/settings_state.go @@ -26,8 +26,9 @@ type SettingsBackend interface { type SettingsConflictScope string const ( - SettingsConflictConfig SettingsConflictScope = "config" - SettingsConflictCredentials SettingsConflictScope = "credentials" + SettingsConflictConfig SettingsConflictScope = "config" + SettingsConflictCredentials SettingsConflictScope = "credentials" + SettingsConflictPeopleCredentials SettingsConflictScope = "people_credentials" ) // SettingsConflictError reports an optimistic-concurrency conflict. The TUI @@ -178,11 +179,14 @@ type SettingsSaveRequest struct { } type settingsState struct { - active bool - loading bool - saving bool - confirmDiscard bool - narrowFields bool + active bool + codex codexSettingsState + peopleControls peopleInferenceControlState + peopleInferenceStatus *PeopleInferenceStatus + loading bool + saving bool + confirmDiscard bool + narrowFields bool groups []SettingsGroup fields []SettingField diff --git a/internal/tui/settings_view.go b/internal/tui/settings_view.go index 688df2a04..3cecfb1e4 100644 --- a/internal/tui/settings_view.go +++ b/internal/tui/settings_view.go @@ -9,6 +9,12 @@ import ( ) func (m Model) renderSettingsView() string { + if m.settings.peopleControls.active { + return m.renderPeopleInferenceControls() + } + if m.settings.codex.active { + return m.renderCodexSettings() + } title := "Settings" if m.settings.dirty() { title += " *" @@ -27,6 +33,23 @@ func (m Model) renderSettingsView() string { } lines := []string{header} + if status := m.settings.peopleInferenceStatus; status != nil { + configured := textutil.SanitizeTerminal(status.Configured) + running := textutil.SanitizeTerminal(status.Running) + if configured == "" { + configured = "none" + } + if running == "" { + running = "none" + } + lines = append(lines, strings.Join(wrapText("People inference: "+configured+" Ā· Running: "+running, max(m.width, 20)), "\n")) + if status.ConfiguredFingerprint != "" { + lines = append(lines, strings.Join(wrapText("Profile fingerprint: "+textutil.SanitizeTerminal(status.ConfiguredFingerprint), max(m.width, 20)), "\n")) + } + if status.PendingRestart { + lines = append(lines, "Restart the daemon to apply the people inference selection.") + } + } if m.settings.pendingRestart { lines = append(lines, m.styles.flash.Render("Pending restart — saved changes take effect after the daemon restarts.")) } @@ -211,6 +234,13 @@ func (m Model) settingsFieldValue(field SettingField) string { } func (m Model) settingsFooter() string { + peopleEntry := "" + if m.peopleInferenceBackend() != nil { + peopleEntry = " [p] People inference" + } + if m.peopleInferenceControlBackend() != nil { + peopleEntry += " [i] People inference controls" + } if m.settings.saving { return "Saving settings…" } @@ -221,11 +251,11 @@ func (m Model) settingsFooter() string { return "[Enter] Use value [Ctrl+S] Use & save [Esc] Cancel" } if m.settingsIsNarrow() && !m.settings.narrowFields { - return "[j/k] Category [Enter/l] Open [Esc] Back" + return "[j/k] Category [Enter/l] Open" + peopleEntry + " [Esc] Back" } if field, ok := m.settings.selectedField(); ok && field.Kind == SettingKindSecret && !field.ReadOnly { - return "[j/k] Row [h/l] Category [Enter] Set secret [x] Clear [Ctrl+S] Save [Esc] Back" + return "[j/k] Row [h/l] Category [Enter] Set secret [x] Clear" + peopleEntry + " [Ctrl+S] Save [Esc] Back" } - return "[j/k] Row [h/l] Category [Enter] Edit [Space] Toggle [Ctrl+S] Save [Esc] Back" + return "[j/k] Row [h/l] Category [Enter] Edit [Space] Toggle" + peopleEntry + " [Ctrl+S] Save [Esc] Back" } diff --git a/pkg/client/generated/client.go b/pkg/client/generated/client.go index 01408a90a..e2f9f3d0c 100644 --- a/pkg/client/generated/client.go +++ b/pkg/client/generated/client.go @@ -1007,6 +1007,66 @@ type ClientInterface interface { PatchSettings(ctx context.Context, options *PatchSettingsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PatchSettingsResponse, error) PatchSettingsWithResponse(ctx context.Context, options *PatchSettingsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PatchSettingsResp, error) + // GetSettingsPeopleInference Get people inference provider status + GetSettingsPeopleInference(ctx context.Context, reqEditors ...runtime.RequestEditorFn) (*GetSettingsPeopleInferenceResponse, error) + GetSettingsPeopleInferenceWithResponse(ctx context.Context, reqEditors ...runtime.RequestEditorFn) (*GetSettingsPeopleInferenceResp, error) + + // StartSettingsPeopleCodexLogin Start a private Codex device login + StartSettingsPeopleCodexLogin(ctx context.Context, options *StartSettingsPeopleCodexLoginRequestOptions, reqEditors ...runtime.RequestEditorFn) (*StartSettingsPeopleCodexLoginResponse, error) + StartSettingsPeopleCodexLoginWithResponse(ctx context.Context, options *StartSettingsPeopleCodexLoginRequestOptions, reqEditors ...runtime.RequestEditorFn) (*StartSettingsPeopleCodexLoginResp, error) + + // CancelSettingsPeopleCodexLogin Cancel Codex device login + CancelSettingsPeopleCodexLogin(ctx context.Context, options *CancelSettingsPeopleCodexLoginRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CancelSettingsPeopleCodexLoginResponse, error) + CancelSettingsPeopleCodexLoginWithResponse(ctx context.Context, options *CancelSettingsPeopleCodexLoginRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CancelSettingsPeopleCodexLoginResp, error) + + // GetSettingsPeopleCodexLogin Get Codex device login status + GetSettingsPeopleCodexLogin(ctx context.Context, options *GetSettingsPeopleCodexLoginRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetSettingsPeopleCodexLoginResponse, error) + GetSettingsPeopleCodexLoginWithResponse(ctx context.Context, options *GetSettingsPeopleCodexLoginRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetSettingsPeopleCodexLoginResp, error) + + // GetSettingsPeopleCodexModels List models for completed Codex login + GetSettingsPeopleCodexModels(ctx context.Context, options *GetSettingsPeopleCodexModelsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetSettingsPeopleCodexModelsResponse, error) + GetSettingsPeopleCodexModelsWithResponse(ctx context.Context, options *GetSettingsPeopleCodexModelsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetSettingsPeopleCodexModelsResp, error) + + // PutSettingsPeopleCodexProfile Create a Codex profile from completed device login + PutSettingsPeopleCodexProfile(ctx context.Context, options *PutSettingsPeopleCodexProfileRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PutSettingsPeopleCodexProfileResponse, error) + PutSettingsPeopleCodexProfileWithResponse(ctx context.Context, options *PutSettingsPeopleCodexProfileRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PutSettingsPeopleCodexProfileResp, error) + + // DisableSettingsPeopleInference Disable people inference and revoke active consent + DisableSettingsPeopleInference(ctx context.Context, options *DisableSettingsPeopleInferenceRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DisableSettingsPeopleInferenceResponse, error) + DisableSettingsPeopleInferenceWithResponse(ctx context.Context, options *DisableSettingsPeopleInferenceRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DisableSettingsPeopleInferenceResp, error) + + // DeleteSettingsPeopleInferenceProvider Remove a people inference provider profile + DeleteSettingsPeopleInferenceProvider(ctx context.Context, options *DeleteSettingsPeopleInferenceProviderRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DeleteSettingsPeopleInferenceProviderResponse, error) + DeleteSettingsPeopleInferenceProviderWithResponse(ctx context.Context, options *DeleteSettingsPeopleInferenceProviderRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DeleteSettingsPeopleInferenceProviderResp, error) + + // PutSettingsPeopleInferencePreset Create a vendor-bound people inference provider + PutSettingsPeopleInferencePreset(ctx context.Context, options *PutSettingsPeopleInferencePresetRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PutSettingsPeopleInferencePresetResponse, error) + PutSettingsPeopleInferencePresetWithResponse(ctx context.Context, options *PutSettingsPeopleInferencePresetRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PutSettingsPeopleInferencePresetResp, error) + + // CheckSettingsPeopleInferenceProvider Run a synthetic people inference provider check + CheckSettingsPeopleInferenceProvider(ctx context.Context, options *CheckSettingsPeopleInferenceProviderRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CheckSettingsPeopleInferenceProviderResponse, error) + CheckSettingsPeopleInferenceProviderWithResponse(ctx context.Context, options *CheckSettingsPeopleInferenceProviderRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CheckSettingsPeopleInferenceProviderResp, error) + + // ConsentSettingsPeopleInferenceProvider Grant exact people inference consent + ConsentSettingsPeopleInferenceProvider(ctx context.Context, options *ConsentSettingsPeopleInferenceProviderRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ConsentSettingsPeopleInferenceProviderResponse, error) + ConsentSettingsPeopleInferenceProviderWithResponse(ctx context.Context, options *ConsentSettingsPeopleInferenceProviderRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ConsentSettingsPeopleInferenceProviderResp, error) + + // DeleteSettingsPeopleInferenceKey Clear a stored people inference API key + DeleteSettingsPeopleInferenceKey(ctx context.Context, options *DeleteSettingsPeopleInferenceKeyRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DeleteSettingsPeopleInferenceKeyResponse, error) + DeleteSettingsPeopleInferenceKeyWithResponse(ctx context.Context, options *DeleteSettingsPeopleInferenceKeyRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DeleteSettingsPeopleInferenceKeyResp, error) + + // PutSettingsPeopleInferenceKey Set a write-only people inference API key + PutSettingsPeopleInferenceKey(ctx context.Context, options *PutSettingsPeopleInferenceKeyRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PutSettingsPeopleInferenceKeyResponse, error) + PutSettingsPeopleInferenceKeyWithResponse(ctx context.Context, options *PutSettingsPeopleInferenceKeyRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PutSettingsPeopleInferenceKeyResp, error) + + // RevokeSettingsPeopleInferenceProvider Revoke exact people inference consent + RevokeSettingsPeopleInferenceProvider(ctx context.Context, options *RevokeSettingsPeopleInferenceProviderRequestOptions, reqEditors ...runtime.RequestEditorFn) (*RevokeSettingsPeopleInferenceProviderResponse, error) + RevokeSettingsPeopleInferenceProviderWithResponse(ctx context.Context, options *RevokeSettingsPeopleInferenceProviderRequestOptions, reqEditors ...runtime.RequestEditorFn) (*RevokeSettingsPeopleInferenceProviderResp, error) + + // SelectSettingsPeopleInference Select a checked and consented people inference provider + SelectSettingsPeopleInference(ctx context.Context, options *SelectSettingsPeopleInferenceRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SelectSettingsPeopleInferenceResponse, error) + SelectSettingsPeopleInferenceWithResponse(ctx context.Context, options *SelectSettingsPeopleInferenceRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SelectSettingsPeopleInferenceResp, error) + // PutSettingsPersonEnrichmentProvider Create or update one named person-enrichment provider PutSettingsPersonEnrichmentProvider(ctx context.Context, options *PutSettingsPersonEnrichmentProviderRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PutSettingsPersonEnrichmentProviderResponse, error) PutSettingsPersonEnrichmentProviderWithResponse(ctx context.Context, options *PutSettingsPersonEnrichmentProviderRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PutSettingsPersonEnrichmentProviderResp, error) @@ -16058,6 +16118,956 @@ func (c *Client) PatchSettings(ctx context.Context, options *PatchSettingsReques return responseParser(ctx, resp) } +// GetSettingsPeopleInference Get people inference provider status +func (c *Client) GetSettingsPeopleInference(ctx context.Context, reqEditors ...runtime.RequestEditorFn) (*GetSettingsPeopleInferenceResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference", + Method: "GET", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*GetSettingsPeopleInferenceResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(GetSettingsPeopleInferenceErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetSettingsPeopleInferenceErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(GetSettingsPeopleInferenceResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetSettingsPeopleInferenceResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// StartSettingsPeopleCodexLogin Start a private Codex device login +func (c *Client) StartSettingsPeopleCodexLogin(ctx context.Context, options *StartSettingsPeopleCodexLoginRequestOptions, reqEditors ...runtime.RequestEditorFn) (*StartSettingsPeopleCodexLoginResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/codex/login", + Method: "POST", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*StartSettingsPeopleCodexLoginResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(StartSettingsPeopleCodexLoginErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "StartSettingsPeopleCodexLoginErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(StartSettingsPeopleCodexLoginResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "StartSettingsPeopleCodexLoginResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/codex/login") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// CancelSettingsPeopleCodexLogin Cancel Codex device login +func (c *Client) CancelSettingsPeopleCodexLogin(ctx context.Context, options *CancelSettingsPeopleCodexLoginRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CancelSettingsPeopleCodexLoginResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/codex/login/{id}", + Method: "DELETE", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*CancelSettingsPeopleCodexLoginResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(CancelSettingsPeopleCodexLoginErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CancelSettingsPeopleCodexLoginErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(CancelSettingsPeopleCodexLoginResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CancelSettingsPeopleCodexLoginResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/codex/login/{id}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// GetSettingsPeopleCodexLogin Get Codex device login status +func (c *Client) GetSettingsPeopleCodexLogin(ctx context.Context, options *GetSettingsPeopleCodexLoginRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetSettingsPeopleCodexLoginResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/codex/login/{id}", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*GetSettingsPeopleCodexLoginResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(GetSettingsPeopleCodexLoginErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetSettingsPeopleCodexLoginErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(GetSettingsPeopleCodexLoginResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetSettingsPeopleCodexLoginResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/codex/login/{id}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// GetSettingsPeopleCodexModels List models for completed Codex login +func (c *Client) GetSettingsPeopleCodexModels(ctx context.Context, options *GetSettingsPeopleCodexModelsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetSettingsPeopleCodexModelsResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/codex/login/{id}/models", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*GetSettingsPeopleCodexModelsResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(GetSettingsPeopleCodexModelsErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetSettingsPeopleCodexModelsErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(GetSettingsPeopleCodexModelsResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetSettingsPeopleCodexModelsResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/codex/login/{id}/models") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// PutSettingsPeopleCodexProfile Create a Codex profile from completed device login +func (c *Client) PutSettingsPeopleCodexProfile(ctx context.Context, options *PutSettingsPeopleCodexProfileRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PutSettingsPeopleCodexProfileResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/codex/login/{id}/profile", + Method: "PUT", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*PutSettingsPeopleCodexProfileResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(PutSettingsPeopleCodexProfileErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleCodexProfileErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(PutSettingsPeopleCodexProfileResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleCodexProfileResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/codex/login/{id}/profile") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// DisableSettingsPeopleInference Disable people inference and revoke active consent +func (c *Client) DisableSettingsPeopleInference(ctx context.Context, options *DisableSettingsPeopleInferenceRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DisableSettingsPeopleInferenceResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/disable", + Method: "POST", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*DisableSettingsPeopleInferenceResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(DisableSettingsPeopleInferenceErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DisableSettingsPeopleInferenceErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(DisableSettingsPeopleInferenceResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DisableSettingsPeopleInferenceResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/disable") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// DeleteSettingsPeopleInferenceProvider Remove a people inference provider profile +func (c *Client) DeleteSettingsPeopleInferenceProvider(ctx context.Context, options *DeleteSettingsPeopleInferenceProviderRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DeleteSettingsPeopleInferenceProviderResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/providers/{name}", + Method: "DELETE", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*DeleteSettingsPeopleInferenceProviderResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(DeleteSettingsPeopleInferenceProviderErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsPeopleInferenceProviderErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(DeleteSettingsPeopleInferenceProviderResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsPeopleInferenceProviderResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/providers/{name}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// PutSettingsPeopleInferencePreset Create a vendor-bound people inference provider +func (c *Client) PutSettingsPeopleInferencePreset(ctx context.Context, options *PutSettingsPeopleInferencePresetRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PutSettingsPeopleInferencePresetResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/providers/{name}", + Method: "PUT", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*PutSettingsPeopleInferencePresetResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(PutSettingsPeopleInferencePresetErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleInferencePresetErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(PutSettingsPeopleInferencePresetResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleInferencePresetResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/providers/{name}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// CheckSettingsPeopleInferenceProvider Run a synthetic people inference provider check +func (c *Client) CheckSettingsPeopleInferenceProvider(ctx context.Context, options *CheckSettingsPeopleInferenceProviderRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CheckSettingsPeopleInferenceProviderResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/providers/{name}/check", + Method: "POST", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*CheckSettingsPeopleInferenceProviderResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(CheckSettingsPeopleInferenceProviderErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CheckSettingsPeopleInferenceProviderErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(CheckSettingsPeopleInferenceProviderResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CheckSettingsPeopleInferenceProviderResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/providers/{name}/check") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// ConsentSettingsPeopleInferenceProvider Grant exact people inference consent +func (c *Client) ConsentSettingsPeopleInferenceProvider(ctx context.Context, options *ConsentSettingsPeopleInferenceProviderRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ConsentSettingsPeopleInferenceProviderResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/providers/{name}/consent", + Method: "POST", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*ConsentSettingsPeopleInferenceProviderResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(ConsentSettingsPeopleInferenceProviderErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ConsentSettingsPeopleInferenceProviderErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(ConsentSettingsPeopleInferenceProviderResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ConsentSettingsPeopleInferenceProviderResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/providers/{name}/consent") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// DeleteSettingsPeopleInferenceKey Clear a stored people inference API key +func (c *Client) DeleteSettingsPeopleInferenceKey(ctx context.Context, options *DeleteSettingsPeopleInferenceKeyRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DeleteSettingsPeopleInferenceKeyResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/providers/{name}/key", + Method: "DELETE", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*DeleteSettingsPeopleInferenceKeyResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(DeleteSettingsPeopleInferenceKeyErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsPeopleInferenceKeyErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(DeleteSettingsPeopleInferenceKeyResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsPeopleInferenceKeyResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/providers/{name}/key") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// PutSettingsPeopleInferenceKey Set a write-only people inference API key +func (c *Client) PutSettingsPeopleInferenceKey(ctx context.Context, options *PutSettingsPeopleInferenceKeyRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PutSettingsPeopleInferenceKeyResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/providers/{name}/key", + Method: "PUT", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*PutSettingsPeopleInferenceKeyResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(PutSettingsPeopleInferenceKeyErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleInferenceKeyErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(PutSettingsPeopleInferenceKeyResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleInferenceKeyResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/providers/{name}/key") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// RevokeSettingsPeopleInferenceProvider Revoke exact people inference consent +func (c *Client) RevokeSettingsPeopleInferenceProvider(ctx context.Context, options *RevokeSettingsPeopleInferenceProviderRequestOptions, reqEditors ...runtime.RequestEditorFn) (*RevokeSettingsPeopleInferenceProviderResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/providers/{name}/revoke", + Method: "POST", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*RevokeSettingsPeopleInferenceProviderResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(RevokeSettingsPeopleInferenceProviderErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "RevokeSettingsPeopleInferenceProviderErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(RevokeSettingsPeopleInferenceProviderResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "RevokeSettingsPeopleInferenceProviderResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/providers/{name}/revoke") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// SelectSettingsPeopleInference Select a checked and consented people inference provider +func (c *Client) SelectSettingsPeopleInference(ctx context.Context, options *SelectSettingsPeopleInferenceRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SelectSettingsPeopleInferenceResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/select", + Method: "POST", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*SelectSettingsPeopleInferenceResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(SelectSettingsPeopleInferenceErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "SelectSettingsPeopleInferenceErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(SelectSettingsPeopleInferenceResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "SelectSettingsPeopleInferenceResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/select") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + // PutSettingsPersonEnrichmentProvider Create or update one named person-enrichment provider func (c *Client) PutSettingsPersonEnrichmentProvider(ctx context.Context, options *PutSettingsPersonEnrichmentProviderRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PutSettingsPersonEnrichmentProviderResponse, error) { var err error diff --git a/pkg/client/generated/client_options.go b/pkg/client/generated/client_options.go index df825ebc1..3fa8828f6 100644 --- a/pkg/client/generated/client_options.go +++ b/pkg/client/generated/client_options.go @@ -10268,6 +10268,739 @@ func (o *PatchSettingsRequestOptions) GetHeader() (map[string]string, error) { return runtime.AsMap[string](o.Header) } +// StartSettingsPeopleCodexLoginRequestOptions is the options needed to make a request to StartSettingsPeopleCodexLogin. +type StartSettingsPeopleCodexLoginRequestOptions struct { + Body *StartSettingsPeopleCodexLoginBody +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *StartSettingsPeopleCodexLoginRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.Body != nil { + if v, ok := any(o.Body).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Body", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *StartSettingsPeopleCodexLoginRequestOptions) GetPathParams() (map[string]any, error) { + return nil, nil +} + +// GetQuery returns the query params as a map. +func (o *StartSettingsPeopleCodexLoginRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *StartSettingsPeopleCodexLoginRequestOptions) GetBody() any { + return o.Body +} + +// GetHeader returns the headers as a map. +func (o *StartSettingsPeopleCodexLoginRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +// CancelSettingsPeopleCodexLoginRequestOptions is the options needed to make a request to CancelSettingsPeopleCodexLogin. +type CancelSettingsPeopleCodexLoginRequestOptions struct { + PathParams *CancelSettingsPeopleCodexLoginPath +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *CancelSettingsPeopleCodexLoginRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *CancelSettingsPeopleCodexLoginRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *CancelSettingsPeopleCodexLoginRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *CancelSettingsPeopleCodexLoginRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *CancelSettingsPeopleCodexLoginRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +// GetSettingsPeopleCodexLoginRequestOptions is the options needed to make a request to GetSettingsPeopleCodexLogin. +type GetSettingsPeopleCodexLoginRequestOptions struct { + PathParams *GetSettingsPeopleCodexLoginPath +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *GetSettingsPeopleCodexLoginRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *GetSettingsPeopleCodexLoginRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *GetSettingsPeopleCodexLoginRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *GetSettingsPeopleCodexLoginRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *GetSettingsPeopleCodexLoginRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +// GetSettingsPeopleCodexModelsRequestOptions is the options needed to make a request to GetSettingsPeopleCodexModels. +type GetSettingsPeopleCodexModelsRequestOptions struct { + PathParams *GetSettingsPeopleCodexModelsPath +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *GetSettingsPeopleCodexModelsRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *GetSettingsPeopleCodexModelsRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *GetSettingsPeopleCodexModelsRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *GetSettingsPeopleCodexModelsRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *GetSettingsPeopleCodexModelsRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +// PutSettingsPeopleCodexProfileRequestOptions is the options needed to make a request to PutSettingsPeopleCodexProfile. +type PutSettingsPeopleCodexProfileRequestOptions struct { + PathParams *PutSettingsPeopleCodexProfilePath + Body *PutSettingsPeopleCodexProfileBody + Header *PutSettingsPeopleCodexProfileHeaders +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *PutSettingsPeopleCodexProfileRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + + if o.Body != nil { + if v, ok := any(o.Body).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Body", err) + } + } + } + + if o.Header != nil { + if v, ok := any(o.Header).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Header", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *PutSettingsPeopleCodexProfileRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *PutSettingsPeopleCodexProfileRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *PutSettingsPeopleCodexProfileRequestOptions) GetBody() any { + return o.Body +} + +// GetHeader returns the headers as a map. +func (o *PutSettingsPeopleCodexProfileRequestOptions) GetHeader() (map[string]string, error) { + return runtime.AsMap[string](o.Header) +} + +// DisableSettingsPeopleInferenceRequestOptions is the options needed to make a request to DisableSettingsPeopleInference. +type DisableSettingsPeopleInferenceRequestOptions struct { + Header *DisableSettingsPeopleInferenceHeaders +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *DisableSettingsPeopleInferenceRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.Header != nil { + if v, ok := any(o.Header).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Header", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *DisableSettingsPeopleInferenceRequestOptions) GetPathParams() (map[string]any, error) { + return nil, nil +} + +// GetQuery returns the query params as a map. +func (o *DisableSettingsPeopleInferenceRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *DisableSettingsPeopleInferenceRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *DisableSettingsPeopleInferenceRequestOptions) GetHeader() (map[string]string, error) { + return runtime.AsMap[string](o.Header) +} + +// DeleteSettingsPeopleInferenceProviderRequestOptions is the options needed to make a request to DeleteSettingsPeopleInferenceProvider. +type DeleteSettingsPeopleInferenceProviderRequestOptions struct { + PathParams *DeleteSettingsPeopleInferenceProviderPath + Header *DeleteSettingsPeopleInferenceProviderHeaders +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *DeleteSettingsPeopleInferenceProviderRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + + if o.Header != nil { + if v, ok := any(o.Header).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Header", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *DeleteSettingsPeopleInferenceProviderRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *DeleteSettingsPeopleInferenceProviderRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *DeleteSettingsPeopleInferenceProviderRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *DeleteSettingsPeopleInferenceProviderRequestOptions) GetHeader() (map[string]string, error) { + return runtime.AsMap[string](o.Header) +} + +// PutSettingsPeopleInferencePresetRequestOptions is the options needed to make a request to PutSettingsPeopleInferencePreset. +type PutSettingsPeopleInferencePresetRequestOptions struct { + PathParams *PutSettingsPeopleInferencePresetPath + Body *PutSettingsPeopleInferencePresetBody + Header *PutSettingsPeopleInferencePresetHeaders +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *PutSettingsPeopleInferencePresetRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + + if o.Body != nil { + if v, ok := any(o.Body).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Body", err) + } + } + } + + if o.Header != nil { + if v, ok := any(o.Header).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Header", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *PutSettingsPeopleInferencePresetRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *PutSettingsPeopleInferencePresetRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *PutSettingsPeopleInferencePresetRequestOptions) GetBody() any { + return o.Body +} + +// GetHeader returns the headers as a map. +func (o *PutSettingsPeopleInferencePresetRequestOptions) GetHeader() (map[string]string, error) { + return runtime.AsMap[string](o.Header) +} + +// CheckSettingsPeopleInferenceProviderRequestOptions is the options needed to make a request to CheckSettingsPeopleInferenceProvider. +type CheckSettingsPeopleInferenceProviderRequestOptions struct { + PathParams *CheckSettingsPeopleInferenceProviderPath + Header *CheckSettingsPeopleInferenceProviderHeaders +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *CheckSettingsPeopleInferenceProviderRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + + if o.Header != nil { + if v, ok := any(o.Header).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Header", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *CheckSettingsPeopleInferenceProviderRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *CheckSettingsPeopleInferenceProviderRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *CheckSettingsPeopleInferenceProviderRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *CheckSettingsPeopleInferenceProviderRequestOptions) GetHeader() (map[string]string, error) { + return runtime.AsMap[string](o.Header) +} + +// ConsentSettingsPeopleInferenceProviderRequestOptions is the options needed to make a request to ConsentSettingsPeopleInferenceProvider. +type ConsentSettingsPeopleInferenceProviderRequestOptions struct { + PathParams *ConsentSettingsPeopleInferenceProviderPath + Body *ConsentSettingsPeopleInferenceProviderBody + Header *ConsentSettingsPeopleInferenceProviderHeaders +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *ConsentSettingsPeopleInferenceProviderRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + + if o.Body != nil { + if v, ok := any(o.Body).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Body", err) + } + } + } + + if o.Header != nil { + if v, ok := any(o.Header).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Header", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *ConsentSettingsPeopleInferenceProviderRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *ConsentSettingsPeopleInferenceProviderRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *ConsentSettingsPeopleInferenceProviderRequestOptions) GetBody() any { + return o.Body +} + +// GetHeader returns the headers as a map. +func (o *ConsentSettingsPeopleInferenceProviderRequestOptions) GetHeader() (map[string]string, error) { + return runtime.AsMap[string](o.Header) +} + +// DeleteSettingsPeopleInferenceKeyRequestOptions is the options needed to make a request to DeleteSettingsPeopleInferenceKey. +type DeleteSettingsPeopleInferenceKeyRequestOptions struct { + PathParams *DeleteSettingsPeopleInferenceKeyPath + Header *DeleteSettingsPeopleInferenceKeyHeaders +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *DeleteSettingsPeopleInferenceKeyRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + + if o.Header != nil { + if v, ok := any(o.Header).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Header", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *DeleteSettingsPeopleInferenceKeyRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *DeleteSettingsPeopleInferenceKeyRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *DeleteSettingsPeopleInferenceKeyRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *DeleteSettingsPeopleInferenceKeyRequestOptions) GetHeader() (map[string]string, error) { + return runtime.AsMap[string](o.Header) +} + +// PutSettingsPeopleInferenceKeyRequestOptions is the options needed to make a request to PutSettingsPeopleInferenceKey. +type PutSettingsPeopleInferenceKeyRequestOptions struct { + PathParams *PutSettingsPeopleInferenceKeyPath + Body *PutSettingsPeopleInferenceKeyBody + Header *PutSettingsPeopleInferenceKeyHeaders +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *PutSettingsPeopleInferenceKeyRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + + if o.Body != nil { + if v, ok := any(o.Body).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Body", err) + } + } + } + + if o.Header != nil { + if v, ok := any(o.Header).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Header", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *PutSettingsPeopleInferenceKeyRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *PutSettingsPeopleInferenceKeyRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *PutSettingsPeopleInferenceKeyRequestOptions) GetBody() any { + return o.Body +} + +// GetHeader returns the headers as a map. +func (o *PutSettingsPeopleInferenceKeyRequestOptions) GetHeader() (map[string]string, error) { + return runtime.AsMap[string](o.Header) +} + +// RevokeSettingsPeopleInferenceProviderRequestOptions is the options needed to make a request to RevokeSettingsPeopleInferenceProvider. +type RevokeSettingsPeopleInferenceProviderRequestOptions struct { + PathParams *RevokeSettingsPeopleInferenceProviderPath + Header *RevokeSettingsPeopleInferenceProviderHeaders +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *RevokeSettingsPeopleInferenceProviderRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + + if o.Header != nil { + if v, ok := any(o.Header).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Header", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *RevokeSettingsPeopleInferenceProviderRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *RevokeSettingsPeopleInferenceProviderRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *RevokeSettingsPeopleInferenceProviderRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *RevokeSettingsPeopleInferenceProviderRequestOptions) GetHeader() (map[string]string, error) { + return runtime.AsMap[string](o.Header) +} + +// SelectSettingsPeopleInferenceRequestOptions is the options needed to make a request to SelectSettingsPeopleInference. +type SelectSettingsPeopleInferenceRequestOptions struct { + Body *SelectSettingsPeopleInferenceBody + Header *SelectSettingsPeopleInferenceHeaders +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *SelectSettingsPeopleInferenceRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.Body != nil { + if v, ok := any(o.Body).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Body", err) + } + } + } + + if o.Header != nil { + if v, ok := any(o.Header).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Header", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *SelectSettingsPeopleInferenceRequestOptions) GetPathParams() (map[string]any, error) { + return nil, nil +} + +// GetQuery returns the query params as a map. +func (o *SelectSettingsPeopleInferenceRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *SelectSettingsPeopleInferenceRequestOptions) GetBody() any { + return o.Body +} + +// GetHeader returns the headers as a map. +func (o *SelectSettingsPeopleInferenceRequestOptions) GetHeader() (map[string]string, error) { + return runtime.AsMap[string](o.Header) +} + // PutSettingsPersonEnrichmentProviderRequestOptions is the options needed to make a request to PutSettingsPersonEnrichmentProvider. type PutSettingsPersonEnrichmentProviderRequestOptions struct { PathParams *PutSettingsPersonEnrichmentProviderPath diff --git a/pkg/client/generated/client_with_response.go b/pkg/client/generated/client_with_response.go index 07adc6498..36312b3b5 100644 --- a/pkg/client/generated/client_with_response.go +++ b/pkg/client/generated/client_with_response.go @@ -22217,6 +22217,2204 @@ func (c *Client) PatchSettingsWithResponse(ctx context.Context, options *PatchSe } } +// GetSettingsPeopleInference Get people inference provider status +func (c *Client) GetSettingsPeopleInferenceWithResponse(ctx context.Context, reqEditors ...runtime.RequestEditorFn) (*GetSettingsPeopleInferenceResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference", + Method: "GET", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &GetSettingsPeopleInferenceResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(GetSettingsPeopleInferenceResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetSettingsPeopleInferenceResponse", + Body: bodyBytes, + Err: err, + } + } + } + out.Headers200 = &GetSettingsPeopleInferenceResp200Headers{ + ETag: resp.Headers.Get("ETag"), + } + return out, nil + case 500: + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// StartSettingsPeopleCodexLogin Start a private Codex device login +func (c *Client) StartSettingsPeopleCodexLoginWithResponse(ctx context.Context, options *StartSettingsPeopleCodexLoginRequestOptions, reqEditors ...runtime.RequestEditorFn) (*StartSettingsPeopleCodexLoginResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/codex/login", + Method: "POST", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/codex/login") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &StartSettingsPeopleCodexLoginResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(StartSettingsPeopleCodexLoginResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "StartSettingsPeopleCodexLoginResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 400: + out.JSON400 = new(StartSettingsPeopleCodexLoginErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "StartSettingsPeopleCodexLoginErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 403: + out.JSON403 = new(StartSettingsPeopleCodexLoginErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON403); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "StartSettingsPeopleCodexLoginErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 409: + out.JSON409 = new(StartSettingsPeopleCodexLoginErrorResponseJSON409) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "StartSettingsPeopleCodexLoginErrorResponseJSON409", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 502: + out.JSON502 = new(StartSettingsPeopleCodexLoginErrorResponseJSON502) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON502); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "StartSettingsPeopleCodexLoginErrorResponseJSON502", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(StartSettingsPeopleCodexLoginErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "StartSettingsPeopleCodexLoginErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 504: + out.JSON504 = new(StartSettingsPeopleCodexLoginErrorResponseJSON504) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON504); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "StartSettingsPeopleCodexLoginErrorResponseJSON504", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// CancelSettingsPeopleCodexLogin Cancel Codex device login +func (c *Client) CancelSettingsPeopleCodexLoginWithResponse(ctx context.Context, options *CancelSettingsPeopleCodexLoginRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CancelSettingsPeopleCodexLoginResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/codex/login/{id}", + Method: "DELETE", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/codex/login/{id}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &CancelSettingsPeopleCodexLoginResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(CancelSettingsPeopleCodexLoginResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CancelSettingsPeopleCodexLoginResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 403: + out.JSON403 = new(CancelSettingsPeopleCodexLoginErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON403); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CancelSettingsPeopleCodexLoginErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(CancelSettingsPeopleCodexLoginErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CancelSettingsPeopleCodexLoginErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 409: + out.JSON409 = new(CancelSettingsPeopleCodexLoginErrorResponseJSON409) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CancelSettingsPeopleCodexLoginErrorResponseJSON409", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 502: + out.JSON502 = new(CancelSettingsPeopleCodexLoginErrorResponseJSON502) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON502); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CancelSettingsPeopleCodexLoginErrorResponseJSON502", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(CancelSettingsPeopleCodexLoginErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CancelSettingsPeopleCodexLoginErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// GetSettingsPeopleCodexLogin Get Codex device login status +func (c *Client) GetSettingsPeopleCodexLoginWithResponse(ctx context.Context, options *GetSettingsPeopleCodexLoginRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetSettingsPeopleCodexLoginResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/codex/login/{id}", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/codex/login/{id}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &GetSettingsPeopleCodexLoginResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(GetSettingsPeopleCodexLoginResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetSettingsPeopleCodexLoginResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 403: + out.JSON403 = new(GetSettingsPeopleCodexLoginErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON403); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetSettingsPeopleCodexLoginErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(GetSettingsPeopleCodexLoginErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetSettingsPeopleCodexLoginErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 409: + out.JSON409 = new(GetSettingsPeopleCodexLoginErrorResponseJSON409) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetSettingsPeopleCodexLoginErrorResponseJSON409", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 502: + out.JSON502 = new(GetSettingsPeopleCodexLoginErrorResponseJSON502) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON502); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetSettingsPeopleCodexLoginErrorResponseJSON502", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(GetSettingsPeopleCodexLoginErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetSettingsPeopleCodexLoginErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// GetSettingsPeopleCodexModels List models for completed Codex login +func (c *Client) GetSettingsPeopleCodexModelsWithResponse(ctx context.Context, options *GetSettingsPeopleCodexModelsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetSettingsPeopleCodexModelsResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/codex/login/{id}/models", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/codex/login/{id}/models") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &GetSettingsPeopleCodexModelsResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(GetSettingsPeopleCodexModelsResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetSettingsPeopleCodexModelsResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 403: + out.JSON403 = new(GetSettingsPeopleCodexModelsErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON403); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetSettingsPeopleCodexModelsErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(GetSettingsPeopleCodexModelsErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetSettingsPeopleCodexModelsErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 409: + out.JSON409 = new(GetSettingsPeopleCodexModelsErrorResponseJSON409) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetSettingsPeopleCodexModelsErrorResponseJSON409", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 502: + out.JSON502 = new(GetSettingsPeopleCodexModelsErrorResponseJSON502) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON502); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetSettingsPeopleCodexModelsErrorResponseJSON502", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(GetSettingsPeopleCodexModelsErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetSettingsPeopleCodexModelsErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// PutSettingsPeopleCodexProfile Create a Codex profile from completed device login +func (c *Client) PutSettingsPeopleCodexProfileWithResponse(ctx context.Context, options *PutSettingsPeopleCodexProfileRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PutSettingsPeopleCodexProfileResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/codex/login/{id}/profile", + Method: "PUT", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/codex/login/{id}/profile") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &PutSettingsPeopleCodexProfileResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(PutSettingsPeopleCodexProfileResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleCodexProfileResponse", + Body: bodyBytes, + Err: err, + } + } + } + out.Headers200 = &PutSettingsPeopleCodexProfileResp200Headers{ + ETag: resp.Headers.Get("ETag"), + } + return out, nil + case 400: + out.JSON400 = new(PutSettingsPeopleCodexProfileErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleCodexProfileErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 403: + out.JSON403 = new(PutSettingsPeopleCodexProfileErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON403); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleCodexProfileErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(PutSettingsPeopleCodexProfileErrorResponseJSON404) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleCodexProfileErrorResponseJSON404", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 409: + out.JSON409 = new(PutSettingsPeopleCodexProfileErrorResponseJSON409) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleCodexProfileErrorResponseJSON409", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 412: + out.JSON412 = new(PutSettingsPeopleCodexProfileErrorResponseJSON412) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON412); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleCodexProfileErrorResponseJSON412", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 422: + out.JSON422 = new(PutSettingsPeopleCodexProfileErrorResponseJSON422) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON422); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleCodexProfileErrorResponseJSON422", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 428: + out.JSON428 = new(PutSettingsPeopleCodexProfileErrorResponseJSON428) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON428); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleCodexProfileErrorResponseJSON428", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 500: + out.JSON500 = new(PutSettingsPeopleCodexProfileErrorResponseJSON500) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleCodexProfileErrorResponseJSON500", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 502: + out.JSON502 = new(PutSettingsPeopleCodexProfileErrorResponseJSON502) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON502); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleCodexProfileErrorResponseJSON502", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(PutSettingsPeopleCodexProfileErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleCodexProfileErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// DisableSettingsPeopleInference Disable people inference and revoke active consent +func (c *Client) DisableSettingsPeopleInferenceWithResponse(ctx context.Context, options *DisableSettingsPeopleInferenceRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DisableSettingsPeopleInferenceResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/disable", + Method: "POST", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/disable") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &DisableSettingsPeopleInferenceResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(DisableSettingsPeopleInferenceResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DisableSettingsPeopleInferenceResponse", + Body: bodyBytes, + Err: err, + } + } + } + out.Headers200 = &DisableSettingsPeopleInferenceResp200Headers{ + ETag: resp.Headers.Get("ETag"), + } + return out, nil + case 400: + out.JSON400 = new(DisableSettingsPeopleInferenceErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DisableSettingsPeopleInferenceErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 409: + out.JSON409 = new(DisableSettingsPeopleInferenceErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DisableSettingsPeopleInferenceErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 412: + out.JSON412 = new(DisableSettingsPeopleInferenceErrorResponseJSON412) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON412); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DisableSettingsPeopleInferenceErrorResponseJSON412", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 428: + out.JSON428 = new(DisableSettingsPeopleInferenceErrorResponseJSON428) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON428); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DisableSettingsPeopleInferenceErrorResponseJSON428", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 500: + out.JSON500 = new(DisableSettingsPeopleInferenceErrorResponseJSON500) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DisableSettingsPeopleInferenceErrorResponseJSON500", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(DisableSettingsPeopleInferenceErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DisableSettingsPeopleInferenceErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// DeleteSettingsPeopleInferenceProvider Remove a people inference provider profile +func (c *Client) DeleteSettingsPeopleInferenceProviderWithResponse(ctx context.Context, options *DeleteSettingsPeopleInferenceProviderRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DeleteSettingsPeopleInferenceProviderResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/providers/{name}", + Method: "DELETE", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/providers/{name}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &DeleteSettingsPeopleInferenceProviderResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(DeleteSettingsPeopleInferenceProviderResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsPeopleInferenceProviderResponse", + Body: bodyBytes, + Err: err, + } + } + } + out.Headers200 = &DeleteSettingsPeopleInferenceProviderResp200Headers{ + ETag: resp.Headers.Get("ETag"), + } + return out, nil + case 400: + out.JSON400 = new(DeleteSettingsPeopleInferenceProviderErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsPeopleInferenceProviderErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(DeleteSettingsPeopleInferenceProviderErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsPeopleInferenceProviderErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 409: + out.JSON409 = new(DeleteSettingsPeopleInferenceProviderErrorResponseJSON409) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsPeopleInferenceProviderErrorResponseJSON409", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 412: + out.JSON412 = new(DeleteSettingsPeopleInferenceProviderErrorResponseJSON412) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON412); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsPeopleInferenceProviderErrorResponseJSON412", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 428: + out.JSON428 = new(DeleteSettingsPeopleInferenceProviderErrorResponseJSON428) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON428); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsPeopleInferenceProviderErrorResponseJSON428", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 500: + out.JSON500 = new(DeleteSettingsPeopleInferenceProviderErrorResponseJSON500) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsPeopleInferenceProviderErrorResponseJSON500", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(DeleteSettingsPeopleInferenceProviderErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsPeopleInferenceProviderErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// PutSettingsPeopleInferencePreset Create a vendor-bound people inference provider +func (c *Client) PutSettingsPeopleInferencePresetWithResponse(ctx context.Context, options *PutSettingsPeopleInferencePresetRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PutSettingsPeopleInferencePresetResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/providers/{name}", + Method: "PUT", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/providers/{name}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &PutSettingsPeopleInferencePresetResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(PutSettingsPeopleInferencePresetResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleInferencePresetResponse", + Body: bodyBytes, + Err: err, + } + } + } + out.Headers200 = &PutSettingsPeopleInferencePresetResp200Headers{ + ETag: resp.Headers.Get("ETag"), + } + return out, nil + case 400: + out.JSON400 = new(PutSettingsPeopleInferencePresetErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleInferencePresetErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 409: + out.JSON409 = new(PutSettingsPeopleInferencePresetErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleInferencePresetErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 412: + out.JSON412 = new(PutSettingsPeopleInferencePresetErrorResponseJSON412) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON412); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleInferencePresetErrorResponseJSON412", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 422: + out.JSON422 = new(PutSettingsPeopleInferencePresetErrorResponseJSON422) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON422); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleInferencePresetErrorResponseJSON422", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 428: + out.JSON428 = new(PutSettingsPeopleInferencePresetErrorResponseJSON428) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON428); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleInferencePresetErrorResponseJSON428", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 500: + out.JSON500 = new(PutSettingsPeopleInferencePresetErrorResponseJSON500) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleInferencePresetErrorResponseJSON500", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(PutSettingsPeopleInferencePresetErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleInferencePresetErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// CheckSettingsPeopleInferenceProvider Run a synthetic people inference provider check +func (c *Client) CheckSettingsPeopleInferenceProviderWithResponse(ctx context.Context, options *CheckSettingsPeopleInferenceProviderRequestOptions, reqEditors ...runtime.RequestEditorFn) (*CheckSettingsPeopleInferenceProviderResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/providers/{name}/check", + Method: "POST", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/providers/{name}/check") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &CheckSettingsPeopleInferenceProviderResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(CheckSettingsPeopleInferenceProviderResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CheckSettingsPeopleInferenceProviderResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 400: + out.JSON400 = new(CheckSettingsPeopleInferenceProviderErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CheckSettingsPeopleInferenceProviderErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 409: + out.JSON409 = new(CheckSettingsPeopleInferenceProviderErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CheckSettingsPeopleInferenceProviderErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 412: + out.JSON412 = new(CheckSettingsPeopleInferenceProviderErrorResponseJSON412) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON412); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CheckSettingsPeopleInferenceProviderErrorResponseJSON412", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 428: + out.JSON428 = new(CheckSettingsPeopleInferenceProviderErrorResponseJSON428) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON428); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CheckSettingsPeopleInferenceProviderErrorResponseJSON428", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 500: + out.JSON500 = new(CheckSettingsPeopleInferenceProviderErrorResponseJSON500) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CheckSettingsPeopleInferenceProviderErrorResponseJSON500", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 502: + out.JSON502 = new(CheckSettingsPeopleInferenceProviderErrorResponseJSON502) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON502); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CheckSettingsPeopleInferenceProviderErrorResponseJSON502", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(CheckSettingsPeopleInferenceProviderErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "CheckSettingsPeopleInferenceProviderErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// ConsentSettingsPeopleInferenceProvider Grant exact people inference consent +func (c *Client) ConsentSettingsPeopleInferenceProviderWithResponse(ctx context.Context, options *ConsentSettingsPeopleInferenceProviderRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ConsentSettingsPeopleInferenceProviderResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/providers/{name}/consent", + Method: "POST", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/providers/{name}/consent") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &ConsentSettingsPeopleInferenceProviderResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(ConsentSettingsPeopleInferenceProviderResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ConsentSettingsPeopleInferenceProviderResponse", + Body: bodyBytes, + Err: err, + } + } + } + out.Headers200 = &ConsentSettingsPeopleInferenceProviderResp200Headers{ + ETag: resp.Headers.Get("ETag"), + } + return out, nil + case 400: + out.JSON400 = new(ConsentSettingsPeopleInferenceProviderErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ConsentSettingsPeopleInferenceProviderErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 409: + out.JSON409 = new(ConsentSettingsPeopleInferenceProviderErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ConsentSettingsPeopleInferenceProviderErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 412: + out.JSON412 = new(ConsentSettingsPeopleInferenceProviderErrorResponseJSON412) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON412); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ConsentSettingsPeopleInferenceProviderErrorResponseJSON412", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 428: + out.JSON428 = new(ConsentSettingsPeopleInferenceProviderErrorResponseJSON428) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON428); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ConsentSettingsPeopleInferenceProviderErrorResponseJSON428", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 500: + out.JSON500 = new(ConsentSettingsPeopleInferenceProviderErrorResponseJSON500) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ConsentSettingsPeopleInferenceProviderErrorResponseJSON500", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(ConsentSettingsPeopleInferenceProviderErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ConsentSettingsPeopleInferenceProviderErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// DeleteSettingsPeopleInferenceKey Clear a stored people inference API key +func (c *Client) DeleteSettingsPeopleInferenceKeyWithResponse(ctx context.Context, options *DeleteSettingsPeopleInferenceKeyRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DeleteSettingsPeopleInferenceKeyResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/providers/{name}/key", + Method: "DELETE", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/providers/{name}/key") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &DeleteSettingsPeopleInferenceKeyResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(DeleteSettingsPeopleInferenceKeyResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsPeopleInferenceKeyResponse", + Body: bodyBytes, + Err: err, + } + } + } + out.Headers200 = &DeleteSettingsPeopleInferenceKeyResp200Headers{ + ETag: resp.Headers.Get("ETag"), + } + return out, nil + case 400: + out.JSON400 = new(DeleteSettingsPeopleInferenceKeyErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsPeopleInferenceKeyErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(DeleteSettingsPeopleInferenceKeyErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsPeopleInferenceKeyErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 409: + out.JSON409 = new(DeleteSettingsPeopleInferenceKeyErrorResponseJSON409) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsPeopleInferenceKeyErrorResponseJSON409", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 412: + out.JSON412 = new(DeleteSettingsPeopleInferenceKeyErrorResponseJSON412) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON412); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsPeopleInferenceKeyErrorResponseJSON412", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 428: + out.JSON428 = new(DeleteSettingsPeopleInferenceKeyErrorResponseJSON428) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON428); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsPeopleInferenceKeyErrorResponseJSON428", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 500: + out.JSON500 = new(DeleteSettingsPeopleInferenceKeyErrorResponseJSON500) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsPeopleInferenceKeyErrorResponseJSON500", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(DeleteSettingsPeopleInferenceKeyErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "DeleteSettingsPeopleInferenceKeyErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// PutSettingsPeopleInferenceKey Set a write-only people inference API key +func (c *Client) PutSettingsPeopleInferenceKeyWithResponse(ctx context.Context, options *PutSettingsPeopleInferenceKeyRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PutSettingsPeopleInferenceKeyResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/providers/{name}/key", + Method: "PUT", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/providers/{name}/key") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &PutSettingsPeopleInferenceKeyResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(PutSettingsPeopleInferenceKeyResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleInferenceKeyResponse", + Body: bodyBytes, + Err: err, + } + } + } + out.Headers200 = &PutSettingsPeopleInferenceKeyResp200Headers{ + ETag: resp.Headers.Get("ETag"), + } + return out, nil + case 400: + out.JSON400 = new(PutSettingsPeopleInferenceKeyErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleInferenceKeyErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(PutSettingsPeopleInferenceKeyErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleInferenceKeyErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 409: + out.JSON409 = new(PutSettingsPeopleInferenceKeyErrorResponseJSON409) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleInferenceKeyErrorResponseJSON409", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 412: + out.JSON412 = new(PutSettingsPeopleInferenceKeyErrorResponseJSON412) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON412); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleInferenceKeyErrorResponseJSON412", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 428: + out.JSON428 = new(PutSettingsPeopleInferenceKeyErrorResponseJSON428) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON428); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleInferenceKeyErrorResponseJSON428", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 500: + out.JSON500 = new(PutSettingsPeopleInferenceKeyErrorResponseJSON500) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleInferenceKeyErrorResponseJSON500", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(PutSettingsPeopleInferenceKeyErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PutSettingsPeopleInferenceKeyErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// RevokeSettingsPeopleInferenceProvider Revoke exact people inference consent +func (c *Client) RevokeSettingsPeopleInferenceProviderWithResponse(ctx context.Context, options *RevokeSettingsPeopleInferenceProviderRequestOptions, reqEditors ...runtime.RequestEditorFn) (*RevokeSettingsPeopleInferenceProviderResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/providers/{name}/revoke", + Method: "POST", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/providers/{name}/revoke") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &RevokeSettingsPeopleInferenceProviderResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(RevokeSettingsPeopleInferenceProviderResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "RevokeSettingsPeopleInferenceProviderResponse", + Body: bodyBytes, + Err: err, + } + } + } + out.Headers200 = &RevokeSettingsPeopleInferenceProviderResp200Headers{ + ETag: resp.Headers.Get("ETag"), + } + return out, nil + case 400: + out.JSON400 = new(RevokeSettingsPeopleInferenceProviderErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "RevokeSettingsPeopleInferenceProviderErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 409: + out.JSON409 = new(RevokeSettingsPeopleInferenceProviderErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "RevokeSettingsPeopleInferenceProviderErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 412: + out.JSON412 = new(RevokeSettingsPeopleInferenceProviderErrorResponseJSON412) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON412); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "RevokeSettingsPeopleInferenceProviderErrorResponseJSON412", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 428: + out.JSON428 = new(RevokeSettingsPeopleInferenceProviderErrorResponseJSON428) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON428); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "RevokeSettingsPeopleInferenceProviderErrorResponseJSON428", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 500: + out.JSON500 = new(RevokeSettingsPeopleInferenceProviderErrorResponseJSON500) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "RevokeSettingsPeopleInferenceProviderErrorResponseJSON500", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(RevokeSettingsPeopleInferenceProviderErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "RevokeSettingsPeopleInferenceProviderErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// SelectSettingsPeopleInference Select a checked and consented people inference provider +func (c *Client) SelectSettingsPeopleInferenceWithResponse(ctx context.Context, options *SelectSettingsPeopleInferenceRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SelectSettingsPeopleInferenceResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/settings/people-inference/select", + Method: "POST", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/settings/people-inference/select") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &SelectSettingsPeopleInferenceResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(SelectSettingsPeopleInferenceResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "SelectSettingsPeopleInferenceResponse", + Body: bodyBytes, + Err: err, + } + } + } + out.Headers200 = &SelectSettingsPeopleInferenceResp200Headers{ + ETag: resp.Headers.Get("ETag"), + } + return out, nil + case 400: + out.JSON400 = new(SelectSettingsPeopleInferenceErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "SelectSettingsPeopleInferenceErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 409: + out.JSON409 = new(SelectSettingsPeopleInferenceErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "SelectSettingsPeopleInferenceErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 412: + out.JSON412 = new(SelectSettingsPeopleInferenceErrorResponseJSON412) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON412); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "SelectSettingsPeopleInferenceErrorResponseJSON412", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 422: + out.JSON422 = new(SelectSettingsPeopleInferenceErrorResponseJSON422) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON422); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "SelectSettingsPeopleInferenceErrorResponseJSON422", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 428: + out.JSON428 = new(SelectSettingsPeopleInferenceErrorResponseJSON428) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON428); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "SelectSettingsPeopleInferenceErrorResponseJSON428", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 500: + out.JSON500 = new(SelectSettingsPeopleInferenceErrorResponseJSON500) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "SelectSettingsPeopleInferenceErrorResponseJSON500", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(SelectSettingsPeopleInferenceErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "SelectSettingsPeopleInferenceErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + // PutSettingsPersonEnrichmentProvider Create or update one named person-enrichment provider func (c *Client) PutSettingsPersonEnrichmentProviderWithResponse(ctx context.Context, options *PutSettingsPersonEnrichmentProviderRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PutSettingsPersonEnrichmentProviderResp, error) { var err error diff --git a/pkg/client/generated/enums.go b/pkg/client/generated/enums.go index 32fbd258f..044508a06 100644 --- a/pkg/client/generated/enums.go +++ b/pkg/client/generated/enums.go @@ -1753,6 +1753,24 @@ func (p ParticipantCompletionHTTPRowKind) Validate() error { } } +type PeopleInferencePresetCreateRequestPresetID string + +const ( + Openai PeopleInferencePresetCreateRequestPresetID = "openai" + Openrouter PeopleInferencePresetCreateRequestPresetID = "openrouter" + Venice PeopleInferencePresetCreateRequestPresetID = "venice" +) + +// Validate checks if the PeopleInferencePresetCreateRequestPresetID value is valid +func (p PeopleInferencePresetCreateRequestPresetID) Validate() error { + switch p { + case Openai, Openrouter, Venice: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid PeopleInferencePresetCreateRequestPresetID value, got: %v", p)) + } +} + type PersonEnrichmentProviderSettingKind string const ( diff --git a/pkg/client/generated/headers.go b/pkg/client/generated/headers.go index 8c8bd8b2a..1302ce901 100644 --- a/pkg/client/generated/headers.go +++ b/pkg/client/generated/headers.go @@ -246,6 +246,96 @@ func (p PatchSettingsHeaders) Validate() error { return runtime.ConvertValidatorError(typesValidator.Struct(p)) } +type PutSettingsPeopleCodexProfileHeaders struct { + // IfMatch Strong config ETag returned by the latest settings read + IfMatch string `json:"If-Match" validate:"required"` +} + +func (p PutSettingsPeopleCodexProfileHeaders) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + +type DisableSettingsPeopleInferenceHeaders struct { + // IfMatch Strong config ETag returned by the latest settings read + IfMatch string `json:"If-Match" validate:"required"` +} + +func (d DisableSettingsPeopleInferenceHeaders) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(d)) +} + +type DeleteSettingsPeopleInferenceProviderHeaders struct { + // IfMatch Strong config ETag returned by the latest settings read + IfMatch string `json:"If-Match" validate:"required"` +} + +func (d DeleteSettingsPeopleInferenceProviderHeaders) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(d)) +} + +type PutSettingsPeopleInferencePresetHeaders struct { + // IfMatch Strong config ETag returned by the latest settings read + IfMatch string `json:"If-Match" validate:"required"` +} + +func (p PutSettingsPeopleInferencePresetHeaders) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + +type CheckSettingsPeopleInferenceProviderHeaders struct { + // IfMatch Strong config ETag returned by the latest settings read + IfMatch string `json:"If-Match" validate:"required"` +} + +func (c CheckSettingsPeopleInferenceProviderHeaders) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(c)) +} + +type ConsentSettingsPeopleInferenceProviderHeaders struct { + // IfMatch Strong config ETag returned by the latest settings read + IfMatch string `json:"If-Match" validate:"required"` +} + +func (c ConsentSettingsPeopleInferenceProviderHeaders) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(c)) +} + +type DeleteSettingsPeopleInferenceKeyHeaders struct { + // IfMatch Opaque revision for this people provider credential + IfMatch string `json:"If-Match" validate:"required"` +} + +func (d DeleteSettingsPeopleInferenceKeyHeaders) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(d)) +} + +type PutSettingsPeopleInferenceKeyHeaders struct { + // IfMatch Opaque revision for this people provider credential + IfMatch string `json:"If-Match" validate:"required"` +} + +func (p PutSettingsPeopleInferenceKeyHeaders) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + +type RevokeSettingsPeopleInferenceProviderHeaders struct { + // IfMatch Strong config ETag returned by the latest settings read + IfMatch string `json:"If-Match" validate:"required"` +} + +func (r RevokeSettingsPeopleInferenceProviderHeaders) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(r)) +} + +type SelectSettingsPeopleInferenceHeaders struct { + // IfMatch Strong config ETag returned by the latest settings read + IfMatch string `json:"If-Match" validate:"required"` +} + +func (s SelectSettingsPeopleInferenceHeaders) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(s)) +} + type PutSettingsPersonEnrichmentProviderHeaders struct { // IfMatch Strong config ETag returned by the latest settings read IfMatch string `json:"If-Match" validate:"required"` diff --git a/pkg/client/generated/paths.go b/pkg/client/generated/paths.go index 6e226d9d2..a5bf27d6a 100644 --- a/pkg/client/generated/paths.go +++ b/pkg/client/generated/paths.go @@ -788,6 +788,94 @@ type RunSavedViewPath struct { ID int64 `json:"id"` } +type CancelSettingsPeopleCodexLoginPath struct { + ID string `json:"id" validate:"required"` +} + +func (c CancelSettingsPeopleCodexLoginPath) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(c)) +} + +type GetSettingsPeopleCodexLoginPath struct { + ID string `json:"id" validate:"required"` +} + +func (g GetSettingsPeopleCodexLoginPath) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(g)) +} + +type GetSettingsPeopleCodexModelsPath struct { + ID string `json:"id" validate:"required"` +} + +func (g GetSettingsPeopleCodexModelsPath) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(g)) +} + +type PutSettingsPeopleCodexProfilePath struct { + ID string `json:"id" validate:"required"` +} + +func (p PutSettingsPeopleCodexProfilePath) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + +type DeleteSettingsPeopleInferenceProviderPath struct { + Name string `json:"name" validate:"required"` +} + +func (d DeleteSettingsPeopleInferenceProviderPath) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(d)) +} + +type PutSettingsPeopleInferencePresetPath struct { + Name string `json:"name" validate:"required"` +} + +func (p PutSettingsPeopleInferencePresetPath) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + +type CheckSettingsPeopleInferenceProviderPath struct { + Name string `json:"name" validate:"required"` +} + +func (c CheckSettingsPeopleInferenceProviderPath) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(c)) +} + +type ConsentSettingsPeopleInferenceProviderPath struct { + Name string `json:"name" validate:"required"` +} + +func (c ConsentSettingsPeopleInferenceProviderPath) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(c)) +} + +type DeleteSettingsPeopleInferenceKeyPath struct { + Name string `json:"name" validate:"required"` +} + +func (d DeleteSettingsPeopleInferenceKeyPath) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(d)) +} + +type PutSettingsPeopleInferenceKeyPath struct { + Name string `json:"name" validate:"required"` +} + +func (p PutSettingsPeopleInferenceKeyPath) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + +type RevokeSettingsPeopleInferenceProviderPath struct { + Name string `json:"name" validate:"required"` +} + +func (r RevokeSettingsPeopleInferenceProviderPath) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(r)) +} + type PutSettingsPersonEnrichmentProviderPath struct { Name string `json:"name" validate:"required"` } diff --git a/pkg/client/generated/payloads.go b/pkg/client/generated/payloads.go index 01249fb8d..b27f76af4 100644 --- a/pkg/client/generated/payloads.go +++ b/pkg/client/generated/payloads.go @@ -206,6 +206,18 @@ type GetSearchCoverageBody = SearchCoverageRequest type PatchSettingsBody = SettingsPatchRequest +type StartSettingsPeopleCodexLoginBody = PeopleCodexLoginRequest + +type PutSettingsPeopleCodexProfileBody = PeopleCodexProfileRequest + +type PutSettingsPeopleInferencePresetBody = PeopleInferencePresetCreateRequest + +type ConsentSettingsPeopleInferenceProviderBody = PeopleInferenceConsentRequest + +type PutSettingsPeopleInferenceKeyBody = PeopleInferenceKeyWriteRequest + +type SelectSettingsPeopleInferenceBody = PeopleInferenceSelectionRequest + type PutSettingsPersonEnrichmentProviderBody = PersonEnrichmentProviderUpdate type PutSettingsProviderCredentialBody = ProviderCredentialWriteRequest diff --git a/pkg/client/generated/responses.go b/pkg/client/generated/responses.go index 64cf8ea12..3c45e9aff 100644 --- a/pkg/client/generated/responses.go +++ b/pkg/client/generated/responses.go @@ -2949,6 +2949,220 @@ type PatchSettingsErrorResponseJSON422 = ErrorResponse type PatchSettingsErrorResponseJSON428 = ErrorResponse +type GetSettingsPeopleInferenceResponse = PeopleInferenceSettingsResponse + +type GetSettingsPeopleInferenceErrorResponse = ErrorResponse + +type StartSettingsPeopleCodexLoginResponse = PeopleCodexLoginResponse + +type StartSettingsPeopleCodexLoginErrorResponse = ErrorResponse + +type StartSettingsPeopleCodexLoginErrorResponseJSON = ErrorResponse + +type StartSettingsPeopleCodexLoginErrorResponseJSON409 = ErrorResponse + +type StartSettingsPeopleCodexLoginErrorResponseJSON502 = ErrorResponse + +type StartSettingsPeopleCodexLoginErrorResponseJSON503 = ErrorResponse + +type StartSettingsPeopleCodexLoginErrorResponseJSON504 = ErrorResponse + +type CancelSettingsPeopleCodexLoginResponse = PeopleCodexLoginStatusResponse + +type CancelSettingsPeopleCodexLoginErrorResponse = ErrorResponse + +type CancelSettingsPeopleCodexLoginErrorResponseJSON = ErrorResponse + +type CancelSettingsPeopleCodexLoginErrorResponseJSON409 = ErrorResponse + +type CancelSettingsPeopleCodexLoginErrorResponseJSON502 = ErrorResponse + +type CancelSettingsPeopleCodexLoginErrorResponseJSON503 = ErrorResponse + +type GetSettingsPeopleCodexLoginResponse = PeopleCodexLoginStatusResponse + +type GetSettingsPeopleCodexLoginErrorResponse = ErrorResponse + +type GetSettingsPeopleCodexLoginErrorResponseJSON = ErrorResponse + +type GetSettingsPeopleCodexLoginErrorResponseJSON409 = ErrorResponse + +type GetSettingsPeopleCodexLoginErrorResponseJSON502 = ErrorResponse + +type GetSettingsPeopleCodexLoginErrorResponseJSON503 = ErrorResponse + +type GetSettingsPeopleCodexModelsResponse = PeopleCodexModelsResponse + +type GetSettingsPeopleCodexModelsErrorResponse = ErrorResponse + +type GetSettingsPeopleCodexModelsErrorResponseJSON = ErrorResponse + +type GetSettingsPeopleCodexModelsErrorResponseJSON409 = ErrorResponse + +type GetSettingsPeopleCodexModelsErrorResponseJSON502 = ErrorResponse + +type GetSettingsPeopleCodexModelsErrorResponseJSON503 = ErrorResponse + +type PutSettingsPeopleCodexProfileResponse = PeopleInferenceSettingsResponse + +type PutSettingsPeopleCodexProfileErrorResponse = ErrorResponse + +type PutSettingsPeopleCodexProfileErrorResponseJSON = ErrorResponse + +type PutSettingsPeopleCodexProfileErrorResponseJSON404 = ErrorResponse + +type PutSettingsPeopleCodexProfileErrorResponseJSON409 = ErrorResponse + +type PutSettingsPeopleCodexProfileErrorResponseJSON412 = ErrorResponse + +type PutSettingsPeopleCodexProfileErrorResponseJSON422 = ErrorResponse + +type PutSettingsPeopleCodexProfileErrorResponseJSON428 = ErrorResponse + +type PutSettingsPeopleCodexProfileErrorResponseJSON500 = ErrorResponse + +type PutSettingsPeopleCodexProfileErrorResponseJSON502 = ErrorResponse + +type PutSettingsPeopleCodexProfileErrorResponseJSON503 = ErrorResponse + +type DisableSettingsPeopleInferenceResponse = PeopleInferenceSettingsResponse + +type DisableSettingsPeopleInferenceErrorResponse = ErrorResponse + +type DisableSettingsPeopleInferenceErrorResponseJSON = ErrorResponse + +type DisableSettingsPeopleInferenceErrorResponseJSON412 = ErrorResponse + +type DisableSettingsPeopleInferenceErrorResponseJSON428 = ErrorResponse + +type DisableSettingsPeopleInferenceErrorResponseJSON500 = ErrorResponse + +type DisableSettingsPeopleInferenceErrorResponseJSON503 = ErrorResponse + +type DeleteSettingsPeopleInferenceProviderResponse = PeopleInferenceSettingsResponse + +type DeleteSettingsPeopleInferenceProviderErrorResponse = ErrorResponse + +type DeleteSettingsPeopleInferenceProviderErrorResponseJSON = ErrorResponse + +type DeleteSettingsPeopleInferenceProviderErrorResponseJSON409 = ErrorResponse + +type DeleteSettingsPeopleInferenceProviderErrorResponseJSON412 = ErrorResponse + +type DeleteSettingsPeopleInferenceProviderErrorResponseJSON428 = ErrorResponse + +type DeleteSettingsPeopleInferenceProviderErrorResponseJSON500 = ErrorResponse + +type DeleteSettingsPeopleInferenceProviderErrorResponseJSON503 = ErrorResponse + +type PutSettingsPeopleInferencePresetResponse = PeopleInferenceSettingsResponse + +type PutSettingsPeopleInferencePresetErrorResponse = ErrorResponse + +type PutSettingsPeopleInferencePresetErrorResponseJSON = ErrorResponse + +type PutSettingsPeopleInferencePresetErrorResponseJSON412 = ErrorResponse + +type PutSettingsPeopleInferencePresetErrorResponseJSON422 = ErrorResponse + +type PutSettingsPeopleInferencePresetErrorResponseJSON428 = ErrorResponse + +type PutSettingsPeopleInferencePresetErrorResponseJSON500 = ErrorResponse + +type PutSettingsPeopleInferencePresetErrorResponseJSON503 = ErrorResponse + +type CheckSettingsPeopleInferenceProviderResponse = PeopleInferenceCheckResponse + +type CheckSettingsPeopleInferenceProviderErrorResponse = ErrorResponse + +type CheckSettingsPeopleInferenceProviderErrorResponseJSON = ErrorResponse + +type CheckSettingsPeopleInferenceProviderErrorResponseJSON412 = ErrorResponse + +type CheckSettingsPeopleInferenceProviderErrorResponseJSON428 = ErrorResponse + +type CheckSettingsPeopleInferenceProviderErrorResponseJSON500 = ErrorResponse + +type CheckSettingsPeopleInferenceProviderErrorResponseJSON502 = ErrorResponse + +type CheckSettingsPeopleInferenceProviderErrorResponseJSON503 = ErrorResponse + +type ConsentSettingsPeopleInferenceProviderResponse = PeopleInferenceSettingsResponse + +type ConsentSettingsPeopleInferenceProviderErrorResponse = ErrorResponse + +type ConsentSettingsPeopleInferenceProviderErrorResponseJSON = ErrorResponse + +type ConsentSettingsPeopleInferenceProviderErrorResponseJSON412 = ErrorResponse + +type ConsentSettingsPeopleInferenceProviderErrorResponseJSON428 = ErrorResponse + +type ConsentSettingsPeopleInferenceProviderErrorResponseJSON500 = ErrorResponse + +type ConsentSettingsPeopleInferenceProviderErrorResponseJSON503 = ErrorResponse + +type DeleteSettingsPeopleInferenceKeyResponse = PeopleInferenceSettingsResponse + +type DeleteSettingsPeopleInferenceKeyErrorResponse = ErrorResponse + +type DeleteSettingsPeopleInferenceKeyErrorResponseJSON = ErrorResponse + +type DeleteSettingsPeopleInferenceKeyErrorResponseJSON409 = ErrorResponse + +type DeleteSettingsPeopleInferenceKeyErrorResponseJSON412 = ErrorResponse + +type DeleteSettingsPeopleInferenceKeyErrorResponseJSON428 = ErrorResponse + +type DeleteSettingsPeopleInferenceKeyErrorResponseJSON500 = ErrorResponse + +type DeleteSettingsPeopleInferenceKeyErrorResponseJSON503 = ErrorResponse + +type PutSettingsPeopleInferenceKeyResponse = PeopleInferenceSettingsResponse + +type PutSettingsPeopleInferenceKeyErrorResponse = ErrorResponse + +type PutSettingsPeopleInferenceKeyErrorResponseJSON = ErrorResponse + +type PutSettingsPeopleInferenceKeyErrorResponseJSON409 = ErrorResponse + +type PutSettingsPeopleInferenceKeyErrorResponseJSON412 = ErrorResponse + +type PutSettingsPeopleInferenceKeyErrorResponseJSON428 = ErrorResponse + +type PutSettingsPeopleInferenceKeyErrorResponseJSON500 = ErrorResponse + +type PutSettingsPeopleInferenceKeyErrorResponseJSON503 = ErrorResponse + +type RevokeSettingsPeopleInferenceProviderResponse = PeopleInferenceSettingsResponse + +type RevokeSettingsPeopleInferenceProviderErrorResponse = ErrorResponse + +type RevokeSettingsPeopleInferenceProviderErrorResponseJSON = ErrorResponse + +type RevokeSettingsPeopleInferenceProviderErrorResponseJSON412 = ErrorResponse + +type RevokeSettingsPeopleInferenceProviderErrorResponseJSON428 = ErrorResponse + +type RevokeSettingsPeopleInferenceProviderErrorResponseJSON500 = ErrorResponse + +type RevokeSettingsPeopleInferenceProviderErrorResponseJSON503 = ErrorResponse + +type SelectSettingsPeopleInferenceResponse = PeopleInferenceSettingsResponse + +type SelectSettingsPeopleInferenceErrorResponse = ErrorResponse + +type SelectSettingsPeopleInferenceErrorResponseJSON = ErrorResponse + +type SelectSettingsPeopleInferenceErrorResponseJSON412 = ErrorResponse + +type SelectSettingsPeopleInferenceErrorResponseJSON422 = ErrorResponse + +type SelectSettingsPeopleInferenceErrorResponseJSON428 = ErrorResponse + +type SelectSettingsPeopleInferenceErrorResponseJSON500 = ErrorResponse + +type SelectSettingsPeopleInferenceErrorResponseJSON503 = ErrorResponse + type PutSettingsPersonEnrichmentProviderResponse = SettingsResponse type PutSettingsPersonEnrichmentProviderErrorResponse = ErrorResponse @@ -5645,6 +5859,252 @@ type PatchSettingsResp struct { JSON428 *PatchSettingsErrorResponseJSON428 } +type GetSettingsPeopleInferenceResp200Headers struct { + ETag string `header:"ETag"` +} + +type GetSettingsPeopleInferenceResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *GetSettingsPeopleInferenceResponse + Headers200 *GetSettingsPeopleInferenceResp200Headers +} + +type StartSettingsPeopleCodexLoginResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *StartSettingsPeopleCodexLoginResponse + JSON400 *StartSettingsPeopleCodexLoginErrorResponse + JSON403 *StartSettingsPeopleCodexLoginErrorResponseJSON + JSON409 *StartSettingsPeopleCodexLoginErrorResponseJSON409 + JSON502 *StartSettingsPeopleCodexLoginErrorResponseJSON502 + JSON503 *StartSettingsPeopleCodexLoginErrorResponseJSON503 + JSON504 *StartSettingsPeopleCodexLoginErrorResponseJSON504 +} + +type CancelSettingsPeopleCodexLoginResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *CancelSettingsPeopleCodexLoginResponse + JSON403 *CancelSettingsPeopleCodexLoginErrorResponse + JSON404 *CancelSettingsPeopleCodexLoginErrorResponseJSON + JSON409 *CancelSettingsPeopleCodexLoginErrorResponseJSON409 + JSON502 *CancelSettingsPeopleCodexLoginErrorResponseJSON502 + JSON503 *CancelSettingsPeopleCodexLoginErrorResponseJSON503 +} + +type GetSettingsPeopleCodexLoginResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *GetSettingsPeopleCodexLoginResponse + JSON403 *GetSettingsPeopleCodexLoginErrorResponse + JSON404 *GetSettingsPeopleCodexLoginErrorResponseJSON + JSON409 *GetSettingsPeopleCodexLoginErrorResponseJSON409 + JSON502 *GetSettingsPeopleCodexLoginErrorResponseJSON502 + JSON503 *GetSettingsPeopleCodexLoginErrorResponseJSON503 +} + +type GetSettingsPeopleCodexModelsResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *GetSettingsPeopleCodexModelsResponse + JSON403 *GetSettingsPeopleCodexModelsErrorResponse + JSON404 *GetSettingsPeopleCodexModelsErrorResponseJSON + JSON409 *GetSettingsPeopleCodexModelsErrorResponseJSON409 + JSON502 *GetSettingsPeopleCodexModelsErrorResponseJSON502 + JSON503 *GetSettingsPeopleCodexModelsErrorResponseJSON503 +} + +type PutSettingsPeopleCodexProfileResp200Headers struct { + ETag string `header:"ETag"` +} + +type PutSettingsPeopleCodexProfileResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *PutSettingsPeopleCodexProfileResponse + Headers200 *PutSettingsPeopleCodexProfileResp200Headers + JSON400 *PutSettingsPeopleCodexProfileErrorResponse + JSON403 *PutSettingsPeopleCodexProfileErrorResponseJSON + JSON404 *PutSettingsPeopleCodexProfileErrorResponseJSON404 + JSON409 *PutSettingsPeopleCodexProfileErrorResponseJSON409 + JSON412 *PutSettingsPeopleCodexProfileErrorResponseJSON412 + JSON422 *PutSettingsPeopleCodexProfileErrorResponseJSON422 + JSON428 *PutSettingsPeopleCodexProfileErrorResponseJSON428 + JSON500 *PutSettingsPeopleCodexProfileErrorResponseJSON500 + JSON502 *PutSettingsPeopleCodexProfileErrorResponseJSON502 + JSON503 *PutSettingsPeopleCodexProfileErrorResponseJSON503 +} + +type DisableSettingsPeopleInferenceResp200Headers struct { + ETag string `header:"ETag"` +} + +type DisableSettingsPeopleInferenceResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *DisableSettingsPeopleInferenceResponse + Headers200 *DisableSettingsPeopleInferenceResp200Headers + JSON400 *DisableSettingsPeopleInferenceErrorResponse + JSON409 *DisableSettingsPeopleInferenceErrorResponseJSON + JSON412 *DisableSettingsPeopleInferenceErrorResponseJSON412 + JSON428 *DisableSettingsPeopleInferenceErrorResponseJSON428 + JSON500 *DisableSettingsPeopleInferenceErrorResponseJSON500 + JSON503 *DisableSettingsPeopleInferenceErrorResponseJSON503 +} + +type DeleteSettingsPeopleInferenceProviderResp200Headers struct { + ETag string `header:"ETag"` +} + +type DeleteSettingsPeopleInferenceProviderResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *DeleteSettingsPeopleInferenceProviderResponse + Headers200 *DeleteSettingsPeopleInferenceProviderResp200Headers + JSON400 *DeleteSettingsPeopleInferenceProviderErrorResponse + JSON404 *DeleteSettingsPeopleInferenceProviderErrorResponseJSON + JSON409 *DeleteSettingsPeopleInferenceProviderErrorResponseJSON409 + JSON412 *DeleteSettingsPeopleInferenceProviderErrorResponseJSON412 + JSON428 *DeleteSettingsPeopleInferenceProviderErrorResponseJSON428 + JSON500 *DeleteSettingsPeopleInferenceProviderErrorResponseJSON500 + JSON503 *DeleteSettingsPeopleInferenceProviderErrorResponseJSON503 +} + +type PutSettingsPeopleInferencePresetResp200Headers struct { + ETag string `header:"ETag"` +} + +type PutSettingsPeopleInferencePresetResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *PutSettingsPeopleInferencePresetResponse + Headers200 *PutSettingsPeopleInferencePresetResp200Headers + JSON400 *PutSettingsPeopleInferencePresetErrorResponse + JSON409 *PutSettingsPeopleInferencePresetErrorResponseJSON + JSON412 *PutSettingsPeopleInferencePresetErrorResponseJSON412 + JSON422 *PutSettingsPeopleInferencePresetErrorResponseJSON422 + JSON428 *PutSettingsPeopleInferencePresetErrorResponseJSON428 + JSON500 *PutSettingsPeopleInferencePresetErrorResponseJSON500 + JSON503 *PutSettingsPeopleInferencePresetErrorResponseJSON503 +} + +type CheckSettingsPeopleInferenceProviderResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *CheckSettingsPeopleInferenceProviderResponse + JSON400 *CheckSettingsPeopleInferenceProviderErrorResponse + JSON409 *CheckSettingsPeopleInferenceProviderErrorResponseJSON + JSON412 *CheckSettingsPeopleInferenceProviderErrorResponseJSON412 + JSON428 *CheckSettingsPeopleInferenceProviderErrorResponseJSON428 + JSON500 *CheckSettingsPeopleInferenceProviderErrorResponseJSON500 + JSON502 *CheckSettingsPeopleInferenceProviderErrorResponseJSON502 + JSON503 *CheckSettingsPeopleInferenceProviderErrorResponseJSON503 +} + +type ConsentSettingsPeopleInferenceProviderResp200Headers struct { + ETag string `header:"ETag"` +} + +type ConsentSettingsPeopleInferenceProviderResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *ConsentSettingsPeopleInferenceProviderResponse + Headers200 *ConsentSettingsPeopleInferenceProviderResp200Headers + JSON400 *ConsentSettingsPeopleInferenceProviderErrorResponse + JSON409 *ConsentSettingsPeopleInferenceProviderErrorResponseJSON + JSON412 *ConsentSettingsPeopleInferenceProviderErrorResponseJSON412 + JSON428 *ConsentSettingsPeopleInferenceProviderErrorResponseJSON428 + JSON500 *ConsentSettingsPeopleInferenceProviderErrorResponseJSON500 + JSON503 *ConsentSettingsPeopleInferenceProviderErrorResponseJSON503 +} + +type DeleteSettingsPeopleInferenceKeyResp200Headers struct { + ETag string `header:"ETag"` +} + +type DeleteSettingsPeopleInferenceKeyResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *DeleteSettingsPeopleInferenceKeyResponse + Headers200 *DeleteSettingsPeopleInferenceKeyResp200Headers + JSON400 *DeleteSettingsPeopleInferenceKeyErrorResponse + JSON404 *DeleteSettingsPeopleInferenceKeyErrorResponseJSON + JSON409 *DeleteSettingsPeopleInferenceKeyErrorResponseJSON409 + JSON412 *DeleteSettingsPeopleInferenceKeyErrorResponseJSON412 + JSON428 *DeleteSettingsPeopleInferenceKeyErrorResponseJSON428 + JSON500 *DeleteSettingsPeopleInferenceKeyErrorResponseJSON500 + JSON503 *DeleteSettingsPeopleInferenceKeyErrorResponseJSON503 +} + +type PutSettingsPeopleInferenceKeyResp200Headers struct { + ETag string `header:"ETag"` +} + +type PutSettingsPeopleInferenceKeyResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *PutSettingsPeopleInferenceKeyResponse + Headers200 *PutSettingsPeopleInferenceKeyResp200Headers + JSON400 *PutSettingsPeopleInferenceKeyErrorResponse + JSON404 *PutSettingsPeopleInferenceKeyErrorResponseJSON + JSON409 *PutSettingsPeopleInferenceKeyErrorResponseJSON409 + JSON412 *PutSettingsPeopleInferenceKeyErrorResponseJSON412 + JSON428 *PutSettingsPeopleInferenceKeyErrorResponseJSON428 + JSON500 *PutSettingsPeopleInferenceKeyErrorResponseJSON500 + JSON503 *PutSettingsPeopleInferenceKeyErrorResponseJSON503 +} + +type RevokeSettingsPeopleInferenceProviderResp200Headers struct { + ETag string `header:"ETag"` +} + +type RevokeSettingsPeopleInferenceProviderResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *RevokeSettingsPeopleInferenceProviderResponse + Headers200 *RevokeSettingsPeopleInferenceProviderResp200Headers + JSON400 *RevokeSettingsPeopleInferenceProviderErrorResponse + JSON409 *RevokeSettingsPeopleInferenceProviderErrorResponseJSON + JSON412 *RevokeSettingsPeopleInferenceProviderErrorResponseJSON412 + JSON428 *RevokeSettingsPeopleInferenceProviderErrorResponseJSON428 + JSON500 *RevokeSettingsPeopleInferenceProviderErrorResponseJSON500 + JSON503 *RevokeSettingsPeopleInferenceProviderErrorResponseJSON503 +} + +type SelectSettingsPeopleInferenceResp200Headers struct { + ETag string `header:"ETag"` +} + +type SelectSettingsPeopleInferenceResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *SelectSettingsPeopleInferenceResponse + Headers200 *SelectSettingsPeopleInferenceResp200Headers + JSON400 *SelectSettingsPeopleInferenceErrorResponse + JSON409 *SelectSettingsPeopleInferenceErrorResponseJSON + JSON412 *SelectSettingsPeopleInferenceErrorResponseJSON412 + JSON422 *SelectSettingsPeopleInferenceErrorResponseJSON422 + JSON428 *SelectSettingsPeopleInferenceErrorResponseJSON428 + JSON500 *SelectSettingsPeopleInferenceErrorResponseJSON500 + JSON503 *SelectSettingsPeopleInferenceErrorResponseJSON503 +} + type PutSettingsPersonEnrichmentProviderResp200Headers struct { ETag string `header:"ETag"` } diff --git a/pkg/client/generated/types.go b/pkg/client/generated/types.go index 59069daf0..20831b7e9 100644 --- a/pkg/client/generated/types.go +++ b/pkg/client/generated/types.go @@ -2185,6 +2185,17 @@ func (c CliStatsResponse) Validate() error { return errors } +type CodexModel struct { + DefaultReasoningEffort string `json:"default_reasoning_effort" validate:"required"` + DisplayName string `json:"display_name" validate:"required"` + ID string `json:"id" validate:"required"` + SupportedEfforts []string `json:"supported_efforts" validate:"required"` +} + +func (c CodexModel) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(c)) +} + type CommunicationService struct { Aliases []string `json:"aliases" validate:"required"` CreatedAt time.Time `json:"created_at" validate:"required"` @@ -7181,6 +7192,211 @@ func (p PatchSavedViewRequest) Validate() error { return errors } +type PeopleCodexLoginRequest struct { + Name string `json:"name" validate:"required,min=1"` +} + +func (p PeopleCodexLoginRequest) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + +type PeopleCodexLoginResponse struct { + LocalDeadline time.Time `json:"local_deadline" validate:"required"` + SessionID string `json:"session_id" validate:"required"` + UserCode string `json:"user_code" validate:"required"` + VerificationURL string `json:"verification_url" validate:"required"` +} + +func (p PeopleCodexLoginResponse) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + +type PeopleCodexLoginStatusResponse struct { + State string `json:"state" validate:"required"` +} + +func (p PeopleCodexLoginStatusResponse) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + +type PeopleCodexModelsResponse struct { + Models []CodexModel `json:"models" validate:"required"` +} + +func (p PeopleCodexModelsResponse) Validate() error { + var errors runtime.ValidationErrors + for i, item := range p.Models { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Models[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PeopleCodexProfileRequest struct { + AllowSensitive bool `json:"allow_sensitive"` + AllowedSources []string `json:"allowed_sources" validate:"required"` + Model string `json:"model" validate:"required,min=1"` + ReasoningEffort string `json:"reasoning_effort" validate:"required,min=1"` + RetentionPosture string `json:"retention_posture" validate:"required,min=1"` + SourceSince string `json:"source_since" validate:"required"` + SourceUntil *string `json:"source_until,omitzero"` + TrainingPosture string `json:"training_posture" validate:"required,min=1"` +} + +func (p PeopleCodexProfileRequest) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + +type PeopleInferenceCheckResponse struct { + Fingerprint string `json:"fingerprint" validate:"required"` + Model string `json:"model" validate:"required"` + Ok bool `json:"ok"` + Usage TokenUsage `json:"usage"` +} + +func (p PeopleInferenceCheckResponse) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(p.Fingerprint, "required"); err != nil { + errors = errors.Append("Fingerprint", err) + } + if err := typesValidator.Var(p.Model, "required"); err != nil { + errors = errors.Append("Model", err) + } + if v, ok := any(p.Usage).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Usage", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PeopleInferenceConsentRequest struct { + Confirmed bool `json:"confirmed"` + Fingerprint string `json:"fingerprint" validate:"required"` +} + +func (p PeopleInferenceConsentRequest) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + +type PeopleInferenceKeyWriteRequest struct { + Value string `json:"value" validate:"required,min=1"` +} + +func (p PeopleInferenceKeyWriteRequest) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + +type PeopleInferencePresetCreateRequest struct { + AllowSensitive bool `json:"allow_sensitive"` + AllowedSources []string `json:"allowed_sources" validate:"required"` + Model string `json:"model" validate:"required,min=1"` + PresetID PeopleInferencePresetCreateRequestPresetID `json:"preset_id" validate:"required"` + RetentionPosture string `json:"retention_posture" validate:"required,min=1"` + SourceSince string `json:"source_since" validate:"required"` + SourceUntil *string `json:"source_until,omitzero"` + TrainingPosture string `json:"training_posture" validate:"required,min=1"` +} + +func (p PeopleInferencePresetCreateRequest) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(p.AllowedSources, "required"); err != nil { + errors = errors.Append("AllowedSources", err) + } + if err := typesValidator.Var(p.Model, "required,min=1"); err != nil { + errors = errors.Append("Model", err) + } + if v, ok := any(p.PresetID).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PresetID", err) + } + } + if err := typesValidator.Var(p.RetentionPosture, "required,min=1"); err != nil { + errors = errors.Append("RetentionPosture", err) + } + if err := typesValidator.Var(p.SourceSince, "required"); err != nil { + errors = errors.Append("SourceSince", err) + } + if err := typesValidator.Var(p.TrainingPosture, "required,min=1"); err != nil { + errors = errors.Append("TrainingPosture", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PeopleInferenceProfileSetting struct { + AllowSensitive bool `json:"allow_sensitive"` + AllowedSources []string `json:"allowed_sources" validate:"required"` + Checked bool `json:"checked"` + ConsentActive bool `json:"consent_active"` + CredentialConfigured bool `json:"credential_configured"` + CredentialEnv *string `json:"credential_env,omitzero"` + CredentialRevision *string `json:"credential_revision,omitzero"` + CredentialSource string `json:"credential_source" validate:"required"` + Endpoint *string `json:"endpoint,omitzero"` + Fingerprint *string `json:"fingerprint,omitzero"` + Model string `json:"model" validate:"required"` + Name string `json:"name" validate:"required"` + OutputMode string `json:"output_mode" validate:"required"` + PresetID *string `json:"preset_id,omitzero"` + Protocol string `json:"protocol" validate:"required"` + RetentionPosture string `json:"retention_posture" validate:"required"` + Selected bool `json:"selected"` + SourceSince string `json:"source_since" validate:"required"` + SourceUntil *string `json:"source_until,omitzero"` + TrainingPosture string `json:"training_posture" validate:"required"` +} + +func (p PeopleInferenceProfileSetting) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + +type PeopleInferenceSelectionRequest struct { + Name string `json:"name" validate:"required,min=1"` +} + +func (p PeopleInferenceSelectionRequest) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + +type PeopleInferenceSettingsResponse struct { + ConfiguredEnabled bool `json:"configured_enabled"` + ConfiguredFingerprint *string `json:"configured_fingerprint,omitzero"` + ConfiguredName *string `json:"configured_name,omitzero"` + PendingRestart bool `json:"pending_restart"` + Profiles []PeopleInferenceProfileSetting `json:"profiles" validate:"required"` + RunningEnabled bool `json:"running_enabled"` + RunningFingerprint *string `json:"running_fingerprint,omitzero"` + RunningName *string `json:"running_name,omitzero"` + StoredCredentialsSupported bool `json:"stored_credentials_supported"` +} + +func (p PeopleInferenceSettingsResponse) Validate() error { + var errors runtime.ValidationErrors + for i, item := range p.Profiles { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Profiles[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + type PeopleResponse struct { People []Person `json:"people" validate:"required"` } @@ -12188,6 +12404,11 @@ func (t TokenUploadRequest) Validate() error { return runtime.ConvertValidatorError(typesValidator.Struct(t)) } +type TokenUsage struct { + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` +} + type TotalStatsResponse struct { AccountCount int64 `json:"account_count"` ActiveMessages int64 `json:"active_messages"` diff --git a/pkg/client/openapi.yaml b/pkg/client/openapi.yaml index f97335ee7..ba0235278 100644 --- a/pkg/client/openapi.yaml +++ b/pkg/client/openapi.yaml @@ -2330,6 +2330,24 @@ components: required: - stats type: object + CodexModel: + properties: + default_reasoning_effort: + type: string + display_name: + type: string + id: + type: string + supported_efforts: + items: + type: string + type: array + required: + - id + - display_name + - default_reasoning_effort + - supported_efforts + type: object CommunicationService: properties: aliases: @@ -7720,6 +7738,255 @@ components: format: int64 type: integer type: object + PeopleCodexLoginRequest: + additionalProperties: false + properties: + name: + minLength: 1 + type: string + required: + - name + type: object + PeopleCodexLoginResponse: + properties: + local_deadline: + format: date-time + type: string + session_id: + type: string + user_code: + type: string + verification_url: + type: string + required: + - session_id + - verification_url + - user_code + - local_deadline + type: object + PeopleCodexLoginStatusResponse: + properties: + state: + type: string + required: + - state + type: object + PeopleCodexModelsResponse: + properties: + models: + items: + $ref: "#/components/schemas/CodexModel" + type: array + required: + - models + type: object + PeopleCodexProfileRequest: + additionalProperties: false + properties: + allow_sensitive: + type: boolean + allowed_sources: + items: + type: string + minItems: 1 + type: array + model: + minLength: 1 + type: string + reasoning_effort: + minLength: 1 + type: string + retention_posture: + minLength: 1 + type: string + source_since: + type: string + source_until: + type: string + training_posture: + minLength: 1 + type: string + required: + - model + - reasoning_effort + - retention_posture + - training_posture + - allowed_sources + - source_since + - allow_sensitive + type: object + PeopleInferenceCheckResponse: + properties: + fingerprint: + type: string + model: + type: string + ok: + type: boolean + usage: + $ref: "#/components/schemas/TokenUsage" + required: + - ok + - fingerprint + - model + - usage + type: object + PeopleInferenceConsentRequest: + additionalProperties: false + properties: + confirmed: + type: boolean + fingerprint: + type: string + required: + - fingerprint + - confirmed + type: object + PeopleInferenceKeyWriteRequest: + additionalProperties: false + properties: + value: + minLength: 1 + type: string + required: + - value + type: object + PeopleInferencePresetCreateRequest: + additionalProperties: false + properties: + allow_sensitive: + type: boolean + allowed_sources: + items: + type: string + minItems: 1 + type: array + model: + minLength: 1 + type: string + preset_id: + enum: + - openai + - openrouter + - venice + type: string + retention_posture: + minLength: 1 + type: string + source_since: + type: string + source_until: + type: string + training_posture: + minLength: 1 + type: string + required: + - preset_id + - model + - retention_posture + - training_posture + - allowed_sources + - source_since + - allow_sensitive + type: object + PeopleInferenceProfileSetting: + properties: + allow_sensitive: + type: boolean + allowed_sources: + items: + type: string + type: array + checked: + type: boolean + consent_active: + type: boolean + credential_configured: + type: boolean + credential_env: + type: string + credential_revision: + type: string + credential_source: + type: string + endpoint: + type: string + fingerprint: + type: string + model: + type: string + name: + type: string + output_mode: + type: string + preset_id: + type: string + protocol: + type: string + retention_posture: + type: string + selected: + type: boolean + source_since: + type: string + source_until: + type: string + training_posture: + type: string + required: + - name + - selected + - protocol + - model + - credential_source + - credential_configured + - checked + - consent_active + - output_mode + - retention_posture + - training_posture + - allowed_sources + - source_since + - allow_sensitive + type: object + PeopleInferenceSelectionRequest: + additionalProperties: false + properties: + name: + minLength: 1 + type: string + required: + - name + type: object + PeopleInferenceSettingsResponse: + properties: + configured_enabled: + type: boolean + configured_fingerprint: + type: string + configured_name: + type: string + pending_restart: + type: boolean + profiles: + items: + $ref: "#/components/schemas/PeopleInferenceProfileSetting" + type: array + running_enabled: + type: boolean + running_fingerprint: + type: string + running_name: + type: string + stored_credentials_supported: + type: boolean + required: + - stored_credentials_supported + - profiles + - configured_enabled + - running_enabled + - pending_restart + type: object PeopleResponse: properties: people: @@ -12557,6 +12824,18 @@ components: required: - refresh_token type: object + TokenUsage: + properties: + input_tokens: + format: int64 + type: integer + output_tokens: + format: int64 + type: integer + required: + - input_tokens + - output_tokens + type: object TotalStatsResponse: properties: account_count: @@ -26001,6 +26280,1078 @@ paths: summary: Update browser-managed settings tags: - API + /api/v1/settings/people-inference: + get: + operationId: getSettingsPeopleInference + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceSettingsResponse" + description: OK + headers: + ETag: + description: Strong content hash for optimistic concurrency + schema: + type: string + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Get people inference provider status + tags: + - API + /api/v1/settings/people-inference/codex/login: + post: + operationId: startSettingsPeopleCodexLogin + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleCodexLoginRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleCodexLoginResponse" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "502": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "504": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Start a private Codex device login + tags: + - API + /api/v1/settings/people-inference/codex/login/{id}: + delete: + operationId: cancelSettingsPeopleCodexLogin + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleCodexLoginStatusResponse" + description: OK + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "502": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Cancel Codex device login + tags: + - API + get: + operationId: getSettingsPeopleCodexLogin + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleCodexLoginStatusResponse" + description: OK + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "502": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Get Codex device login status + tags: + - API + /api/v1/settings/people-inference/codex/login/{id}/models: + get: + operationId: getSettingsPeopleCodexModels + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleCodexModelsResponse" + description: OK + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "502": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: List models for completed Codex login + tags: + - API + /api/v1/settings/people-inference/codex/login/{id}/profile: + put: + operationId: putSettingsPeopleCodexProfile + parameters: + - in: path + name: id + required: true + schema: + type: string + - description: Strong config ETag returned by the latest settings read + in: header + name: If-Match + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleCodexProfileRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceSettingsResponse" + description: OK + headers: + ETag: + description: Strong content hash for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "412": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "422": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "502": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Create a Codex profile from completed device login + tags: + - API + /api/v1/settings/people-inference/disable: + post: + operationId: disableSettingsPeopleInference + parameters: + - description: Strong config ETag returned by the latest settings read + in: header + name: If-Match + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceSettingsResponse" + description: OK + headers: + ETag: + description: Strong content hash for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "412": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Disable people inference and revoke active consent + tags: + - API + /api/v1/settings/people-inference/providers/{name}: + delete: + operationId: deleteSettingsPeopleInferenceProvider + parameters: + - in: path + name: name + required: true + schema: + type: string + - description: Strong config ETag returned by the latest settings read + in: header + name: If-Match + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceSettingsResponse" + description: OK + headers: + ETag: + description: Strong content hash for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "412": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Remove a people inference provider profile + tags: + - API + put: + operationId: putSettingsPeopleInferencePreset + parameters: + - in: path + name: name + required: true + schema: + type: string + - description: Strong config ETag returned by the latest settings read + in: header + name: If-Match + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferencePresetCreateRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceSettingsResponse" + description: OK + headers: + ETag: + description: Strong content hash for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "412": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "422": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Create a vendor-bound people inference provider + tags: + - API + /api/v1/settings/people-inference/providers/{name}/check: + post: + operationId: checkSettingsPeopleInferenceProvider + parameters: + - in: path + name: name + required: true + schema: + type: string + - description: Strong config ETag returned by the latest settings read + in: header + name: If-Match + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceCheckResponse" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "412": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "502": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Run a synthetic people inference provider check + tags: + - API + /api/v1/settings/people-inference/providers/{name}/consent: + post: + operationId: consentSettingsPeopleInferenceProvider + parameters: + - in: path + name: name + required: true + schema: + type: string + - description: Strong config ETag returned by the latest settings read + in: header + name: If-Match + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceConsentRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceSettingsResponse" + description: OK + headers: + ETag: + description: Strong content hash for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "412": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Grant exact people inference consent + tags: + - API + /api/v1/settings/people-inference/providers/{name}/key: + delete: + operationId: deleteSettingsPeopleInferenceKey + parameters: + - in: path + name: name + required: true + schema: + type: string + - description: Opaque revision for this people provider credential + in: header + name: If-Match + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceSettingsResponse" + description: OK + headers: + ETag: + description: Strong content hash for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "412": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Clear a stored people inference API key + tags: + - API + put: + operationId: putSettingsPeopleInferenceKey + parameters: + - in: path + name: name + required: true + schema: + type: string + - description: Opaque revision for this people provider credential + in: header + name: If-Match + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceKeyWriteRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceSettingsResponse" + description: OK + headers: + ETag: + description: Strong content hash for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "412": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Set a write-only people inference API key + tags: + - API + /api/v1/settings/people-inference/providers/{name}/revoke: + post: + operationId: revokeSettingsPeopleInferenceProvider + parameters: + - in: path + name: name + required: true + schema: + type: string + - description: Strong config ETag returned by the latest settings read + in: header + name: If-Match + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceSettingsResponse" + description: OK + headers: + ETag: + description: Strong content hash for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "412": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Revoke exact people inference consent + tags: + - API + /api/v1/settings/people-inference/select: + post: + operationId: selectSettingsPeopleInference + parameters: + - description: Strong config ETag returned by the latest settings read + in: header + name: If-Match + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceSelectionRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PeopleInferenceSettingsResponse" + description: OK + headers: + ETag: + description: Strong content hash for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "412": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "422": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Select a checked and consented people inference provider + tags: + - API /api/v1/settings/person-enrichment/providers/{name}: put: operationId: putSettingsPersonEnrichmentProvider diff --git a/web/src/lib/api/generated/api/api.ts b/web/src/lib/api/generated/api/api.ts index b408705ec..0c3b9817d 100644 --- a/web/src/lib/api/generated/api/api.ts +++ b/web/src/lib/api/generated/api/api.ts @@ -40,6 +40,7 @@ import type { CacheStats, CancelDeletionPathParameters, CancelDeletionResponse, + CancelSettingsPeopleCodexLoginPathParameters, CardDAVAccountRequest, CardDAVAccountResponse, CardDAVBookResponse, @@ -60,6 +61,7 @@ import type { CardDAVSyncRequest, Catalog, ChangesResponse, + CheckSettingsPeopleInferenceProviderPathParameters, ClearOrganizationAttributeParams, ClearOrganizationAttributePathParameters, ClearPersonAttributeParams, @@ -74,6 +76,7 @@ import type { CliStatsResponse, CommunicationService, CommunicationServicesResponse, + ConsentSettingsPeopleInferenceProviderPathParameters, ContactState, ConversationResponse, CreateAttributeDefinitionRequest, @@ -102,6 +105,8 @@ import type { DeletePersonRelationshipPathParameters, DeleteRelationshipTypePathParameters, DeleteSavedViewPathParameters, + DeleteSettingsPeopleInferenceKeyPathParameters, + DeleteSettingsPeopleInferenceProviderPathParameters, DeleteSettingsProviderCredentialPathParameters, DeletionManifestDetail, DirectoryPeopleResponse, @@ -172,6 +177,8 @@ import type { GetPersonTrackingPathParameters, GetRelationshipTypePathParameters, GetSavedViewPathParameters, + GetSettingsPeopleCodexLoginPathParameters, + GetSettingsPeopleCodexModelsPathParameters, GetSubAggregatesParams, GetTextAggregatesParams, GetTextStatsParams, @@ -270,6 +277,17 @@ import type { PatchRelationshipTypeRequest, PatchSavedViewPathParameters, PatchSavedViewRequest, + PeopleCodexLoginRequest, + PeopleCodexLoginResponse, + PeopleCodexLoginStatusResponse, + PeopleCodexModelsResponse, + PeopleCodexProfileRequest, + PeopleInferenceCheckResponse, + PeopleInferenceConsentRequest, + PeopleInferenceKeyWriteRequest, + PeopleInferencePresetCreateRequest, + PeopleInferenceSelectionRequest, + PeopleInferenceSettingsResponse, PeopleResponse, Person, PersonAgendaCreateRequest, @@ -315,6 +333,9 @@ import type { PutOrganizationProfilePathParameters, PutPersonBriefEnrollmentRequest, PutPersonTrackingRequest, + PutSettingsPeopleCodexProfilePathParameters, + PutSettingsPeopleInferenceKeyPathParameters, + PutSettingsPeopleInferencePresetPathParameters, PutSettingsPersonEnrichmentProviderPathParameters, PutSettingsProviderCredentialPathParameters, QueryRequest, @@ -328,6 +349,7 @@ import type { RemoteImageRequest, ResolveCardDAVConflictPathParameters, RevokeAgentTokenPathParameters, + RevokeSettingsPeopleInferenceProviderPathParameters, RunArchiveQueryParams, RunQueryParams, RunSavedViewPathParameters, @@ -3473,6 +3495,239 @@ export const patchSettings = ( options, ); }; +/** + * @summary Get people inference provider status + */ +export const getSettingsPeopleInference = ( + options?: SecondParameter>, +) => { + return orvalFetch( + { url: `/api/v1/settings/people-inference`, method: "GET" }, + options, + ); +}; +/** + * @summary Start a private Codex device login + */ +export const startSettingsPeopleCodexLogin = ( + peopleCodexLoginRequest: PeopleCodexLoginRequest, + options?: SecondParameter>, +) => { + return orvalFetch( + { + url: `/api/v1/settings/people-inference/codex/login`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: peopleCodexLoginRequest, + }, + options, + ); +}; +/** + * @summary Cancel Codex device login + */ +export const cancelSettingsPeopleCodexLogin = ( + { id }: CancelSettingsPeopleCodexLoginPathParameters, + options?: SecondParameter>, +) => { + return orvalFetch( + { + url: `/api/v1/settings/people-inference/codex/login/${encodeURIComponent(String(id))}`, + method: "DELETE", + }, + options, + ); +}; +/** + * @summary Get Codex device login status + */ +export const getSettingsPeopleCodexLogin = ( + { id }: GetSettingsPeopleCodexLoginPathParameters, + options?: SecondParameter>, +) => { + return orvalFetch( + { + url: `/api/v1/settings/people-inference/codex/login/${encodeURIComponent(String(id))}`, + method: "GET", + }, + options, + ); +}; +/** + * @summary List models for completed Codex login + */ +export const getSettingsPeopleCodexModels = ( + { id }: GetSettingsPeopleCodexModelsPathParameters, + options?: SecondParameter>, +) => { + return orvalFetch( + { + url: `/api/v1/settings/people-inference/codex/login/${encodeURIComponent(String(id))}/models`, + method: "GET", + }, + options, + ); +}; +/** + * @summary Create a Codex profile from completed device login + */ +export const putSettingsPeopleCodexProfile = ( + { id }: PutSettingsPeopleCodexProfilePathParameters, + peopleCodexProfileRequest: PeopleCodexProfileRequest, + options?: SecondParameter>, +) => { + return orvalFetch( + { + url: `/api/v1/settings/people-inference/codex/login/${encodeURIComponent(String(id))}/profile`, + method: "PUT", + headers: { "Content-Type": "application/json" }, + data: peopleCodexProfileRequest, + }, + options, + ); +}; +/** + * @summary Disable people inference and revoke active consent + */ +export const disableSettingsPeopleInference = ( + options?: SecondParameter>, +) => { + return orvalFetch( + { url: `/api/v1/settings/people-inference/disable`, method: "POST" }, + options, + ); +}; +/** + * @summary Remove a people inference provider profile + */ +export const deleteSettingsPeopleInferenceProvider = ( + { name }: DeleteSettingsPeopleInferenceProviderPathParameters, + options?: SecondParameter>, +) => { + return orvalFetch( + { + url: `/api/v1/settings/people-inference/providers/${encodeURIComponent(String(name))}`, + method: "DELETE", + }, + options, + ); +}; +/** + * @summary Create a vendor-bound people inference provider + */ +export const putSettingsPeopleInferencePreset = ( + { name }: PutSettingsPeopleInferencePresetPathParameters, + peopleInferencePresetCreateRequest: PeopleInferencePresetCreateRequest, + options?: SecondParameter>, +) => { + return orvalFetch( + { + url: `/api/v1/settings/people-inference/providers/${encodeURIComponent(String(name))}`, + method: "PUT", + headers: { "Content-Type": "application/json" }, + data: peopleInferencePresetCreateRequest, + }, + options, + ); +}; +/** + * @summary Run a synthetic people inference provider check + */ +export const checkSettingsPeopleInferenceProvider = ( + { name }: CheckSettingsPeopleInferenceProviderPathParameters, + options?: SecondParameter>, +) => { + return orvalFetch( + { + url: `/api/v1/settings/people-inference/providers/${encodeURIComponent(String(name))}/check`, + method: "POST", + }, + options, + ); +}; +/** + * @summary Grant exact people inference consent + */ +export const consentSettingsPeopleInferenceProvider = ( + { name }: ConsentSettingsPeopleInferenceProviderPathParameters, + peopleInferenceConsentRequest: PeopleInferenceConsentRequest, + options?: SecondParameter>, +) => { + return orvalFetch( + { + url: `/api/v1/settings/people-inference/providers/${encodeURIComponent(String(name))}/consent`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: peopleInferenceConsentRequest, + }, + options, + ); +}; +/** + * @summary Clear a stored people inference API key + */ +export const deleteSettingsPeopleInferenceKey = ( + { name }: DeleteSettingsPeopleInferenceKeyPathParameters, + options?: SecondParameter>, +) => { + return orvalFetch( + { + url: `/api/v1/settings/people-inference/providers/${encodeURIComponent(String(name))}/key`, + method: "DELETE", + }, + options, + ); +}; +/** + * @summary Set a write-only people inference API key + */ +export const putSettingsPeopleInferenceKey = ( + { name }: PutSettingsPeopleInferenceKeyPathParameters, + peopleInferenceKeyWriteRequest: PeopleInferenceKeyWriteRequest, + options?: SecondParameter>, +) => { + return orvalFetch( + { + url: `/api/v1/settings/people-inference/providers/${encodeURIComponent(String(name))}/key`, + method: "PUT", + headers: { "Content-Type": "application/json" }, + data: peopleInferenceKeyWriteRequest, + }, + options, + ); +}; +/** + * @summary Revoke exact people inference consent + */ +export const revokeSettingsPeopleInferenceProvider = ( + { name }: RevokeSettingsPeopleInferenceProviderPathParameters, + options?: SecondParameter>, +) => { + return orvalFetch( + { + url: `/api/v1/settings/people-inference/providers/${encodeURIComponent(String(name))}/revoke`, + method: "POST", + }, + options, + ); +}; +/** + * @summary Select a checked and consented people inference provider + */ +export const selectSettingsPeopleInference = ( + peopleInferenceSelectionRequest: PeopleInferenceSelectionRequest, + options?: SecondParameter>, +) => { + return orvalFetch( + { + url: `/api/v1/settings/people-inference/select`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: peopleInferenceSelectionRequest, + }, + options, + ); +}; /** * @summary Create or update one named person-enrichment provider */ diff --git a/web/src/lib/api/generated/models/cancelSettingsPeopleCodexLoginPathParameters.ts b/web/src/lib/api/generated/models/cancelSettingsPeopleCodexLoginPathParameters.ts new file mode 100644 index 000000000..9da370e00 --- /dev/null +++ b/web/src/lib/api/generated/models/cancelSettingsPeopleCodexLoginPathParameters.ts @@ -0,0 +1,7 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export type CancelSettingsPeopleCodexLoginPathParameters = { + id: string; +}; diff --git a/web/src/lib/api/generated/models/checkSettingsPeopleInferenceProviderPathParameters.ts b/web/src/lib/api/generated/models/checkSettingsPeopleInferenceProviderPathParameters.ts new file mode 100644 index 000000000..dddeab9b2 --- /dev/null +++ b/web/src/lib/api/generated/models/checkSettingsPeopleInferenceProviderPathParameters.ts @@ -0,0 +1,7 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export type CheckSettingsPeopleInferenceProviderPathParameters = { + name: string; +}; diff --git a/web/src/lib/api/generated/models/codexModel.ts b/web/src/lib/api/generated/models/codexModel.ts new file mode 100644 index 000000000..b6fe29906 --- /dev/null +++ b/web/src/lib/api/generated/models/codexModel.ts @@ -0,0 +1,11 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export interface CodexModel { + default_reasoning_effort: string; + display_name: string; + id: string; + supported_efforts: string[]; + [key: string]: unknown; +} diff --git a/web/src/lib/api/generated/models/consentSettingsPeopleInferenceProviderPathParameters.ts b/web/src/lib/api/generated/models/consentSettingsPeopleInferenceProviderPathParameters.ts new file mode 100644 index 000000000..4883e127a --- /dev/null +++ b/web/src/lib/api/generated/models/consentSettingsPeopleInferenceProviderPathParameters.ts @@ -0,0 +1,7 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export type ConsentSettingsPeopleInferenceProviderPathParameters = { + name: string; +}; diff --git a/web/src/lib/api/generated/models/deleteSettingsPeopleInferenceKeyPathParameters.ts b/web/src/lib/api/generated/models/deleteSettingsPeopleInferenceKeyPathParameters.ts new file mode 100644 index 000000000..b9564037f --- /dev/null +++ b/web/src/lib/api/generated/models/deleteSettingsPeopleInferenceKeyPathParameters.ts @@ -0,0 +1,7 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export type DeleteSettingsPeopleInferenceKeyPathParameters = { + name: string; +}; diff --git a/web/src/lib/api/generated/models/deleteSettingsPeopleInferenceProviderPathParameters.ts b/web/src/lib/api/generated/models/deleteSettingsPeopleInferenceProviderPathParameters.ts new file mode 100644 index 000000000..df30c8680 --- /dev/null +++ b/web/src/lib/api/generated/models/deleteSettingsPeopleInferenceProviderPathParameters.ts @@ -0,0 +1,7 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export type DeleteSettingsPeopleInferenceProviderPathParameters = { + name: string; +}; diff --git a/web/src/lib/api/generated/models/getSettingsPeopleCodexLoginPathParameters.ts b/web/src/lib/api/generated/models/getSettingsPeopleCodexLoginPathParameters.ts new file mode 100644 index 000000000..a171c2889 --- /dev/null +++ b/web/src/lib/api/generated/models/getSettingsPeopleCodexLoginPathParameters.ts @@ -0,0 +1,7 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export type GetSettingsPeopleCodexLoginPathParameters = { + id: string; +}; diff --git a/web/src/lib/api/generated/models/getSettingsPeopleCodexModelsPathParameters.ts b/web/src/lib/api/generated/models/getSettingsPeopleCodexModelsPathParameters.ts new file mode 100644 index 000000000..d4b144446 --- /dev/null +++ b/web/src/lib/api/generated/models/getSettingsPeopleCodexModelsPathParameters.ts @@ -0,0 +1,7 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export type GetSettingsPeopleCodexModelsPathParameters = { + id: string; +}; diff --git a/web/src/lib/api/generated/models/index.ts b/web/src/lib/api/generated/models/index.ts index 964ab5db5..a8dae6eb3 100644 --- a/web/src/lib/api/generated/models/index.ts +++ b/web/src/lib/api/generated/models/index.ts @@ -51,6 +51,7 @@ export * from "./cacheFreshness"; export * from "./cacheStats"; export * from "./cancelDeletionPathParameters"; export * from "./cancelDeletionResponse"; +export * from "./cancelSettingsPeopleCodexLoginPathParameters"; export * from "./candidate"; export * from "./candidateClassification"; export * from "./cardDAVAccountRequest"; @@ -98,6 +99,7 @@ export * from "./cardDAVSyncRequest"; export * from "./catalog"; export * from "./changedMessageJSON"; export * from "./changesResponse"; +export * from "./checkSettingsPeopleInferenceProviderPathParameters"; export * from "./choiceDescriptor"; export * from "./clearOrganizationAttributeParams"; export * from "./clearOrganizationAttributePathParameters"; @@ -144,9 +146,11 @@ export * from "./cliSearchResponse"; export * from "./cliStatsResponse"; export * from "./cLISyncEvent"; export * from "./cLIVerifyEvent"; +export * from "./codexModel"; export * from "./communicationService"; export * from "./communicationServicesResponse"; export * from "./confidenceInputs"; +export * from "./consentSettingsPeopleInferenceProviderPathParameters"; export * from "./contactState"; export * from "./conversationResponse"; export * from "./createAttributeDefinitionRequest"; @@ -183,6 +187,8 @@ export * from "./deletePersonPathParameters"; export * from "./deletePersonRelationshipPathParameters"; export * from "./deleteRelationshipTypePathParameters"; export * from "./deleteSavedViewPathParameters"; +export * from "./deleteSettingsPeopleInferenceKeyPathParameters"; +export * from "./deleteSettingsPeopleInferenceProviderPathParameters"; export * from "./deleteSettingsProviderCredentialPathParameters"; export * from "./deletionManifestDetail"; export * from "./deletionManifestSummary"; @@ -332,6 +338,8 @@ export * from "./getRelationshipCalendarPathParameters"; export * from "./getRelationshipTimelinePathParameters"; export * from "./getRelationshipTypePathParameters"; export * from "./getSavedViewPathParameters"; +export * from "./getSettingsPeopleCodexLoginPathParameters"; +export * from "./getSettingsPeopleCodexModelsPathParameters"; export * from "./getSubAggregatesParams"; export * from "./getTextAggregatesParams"; export * from "./getTextStatsParams"; @@ -537,6 +545,19 @@ export * from "./patchRelationshipTypePathParameters"; export * from "./patchRelationshipTypeRequest"; export * from "./patchSavedViewPathParameters"; export * from "./patchSavedViewRequest"; +export * from "./peopleCodexLoginRequest"; +export * from "./peopleCodexLoginResponse"; +export * from "./peopleCodexLoginStatusResponse"; +export * from "./peopleCodexModelsResponse"; +export * from "./peopleCodexProfileRequest"; +export * from "./peopleInferenceCheckResponse"; +export * from "./peopleInferenceConsentRequest"; +export * from "./peopleInferenceKeyWriteRequest"; +export * from "./peopleInferencePresetCreateRequest"; +export * from "./peopleInferencePresetCreateRequestPresetId"; +export * from "./peopleInferenceProfileSetting"; +export * from "./peopleInferenceSelectionRequest"; +export * from "./peopleInferenceSettingsResponse"; export * from "./peopleResponse"; export * from "./person"; export * from "./personAddress"; @@ -652,6 +673,9 @@ export * from "./publishCardDAVPersonPathParameters"; export * from "./putOrganizationProfilePathParameters"; export * from "./putPersonBriefEnrollmentRequest"; export * from "./putPersonTrackingRequest"; +export * from "./putSettingsPeopleCodexProfilePathParameters"; +export * from "./putSettingsPeopleInferenceKeyPathParameters"; +export * from "./putSettingsPeopleInferencePresetPathParameters"; export * from "./putSettingsPersonEnrichmentProviderPathParameters"; export * from "./putSettingsProviderCredentialPathParameters"; export * from "./queryRequest"; @@ -681,6 +705,7 @@ export * from "./removeResult"; export * from "./removeResultCacheState"; export * from "./resolveCardDAVConflictPathParameters"; export * from "./revokeAgentTokenPathParameters"; +export * from "./revokeSettingsPeopleInferenceProviderPathParameters"; export * from "./runArchiveQueryParams"; export * from "./runQueryParams"; export * from "./runSavedViewPathParameters"; @@ -798,6 +823,7 @@ export * from "./textMessagesResponse"; export * from "./textSearchResponse"; export * from "./timelineRow"; export * from "./tokenUploadRequest"; +export * from "./tokenUsage"; export * from "./totalStatsResponse"; export * from "./transcriptSegment"; export * from "./triggerSyncParams"; diff --git a/web/src/lib/api/generated/models/peopleCodexLoginRequest.ts b/web/src/lib/api/generated/models/peopleCodexLoginRequest.ts new file mode 100644 index 000000000..2864abb4e --- /dev/null +++ b/web/src/lib/api/generated/models/peopleCodexLoginRequest.ts @@ -0,0 +1,8 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export interface PeopleCodexLoginRequest { + /** @minLength 1 */ + name: string; +} diff --git a/web/src/lib/api/generated/models/peopleCodexLoginResponse.ts b/web/src/lib/api/generated/models/peopleCodexLoginResponse.ts new file mode 100644 index 000000000..0dd6cdbce --- /dev/null +++ b/web/src/lib/api/generated/models/peopleCodexLoginResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export interface PeopleCodexLoginResponse { + local_deadline: string; + session_id: string; + user_code: string; + verification_url: string; + [key: string]: unknown; +} diff --git a/web/src/lib/api/generated/models/peopleCodexLoginStatusResponse.ts b/web/src/lib/api/generated/models/peopleCodexLoginStatusResponse.ts new file mode 100644 index 000000000..cd3f82e01 --- /dev/null +++ b/web/src/lib/api/generated/models/peopleCodexLoginStatusResponse.ts @@ -0,0 +1,8 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export interface PeopleCodexLoginStatusResponse { + state: string; + [key: string]: unknown; +} diff --git a/web/src/lib/api/generated/models/peopleCodexModelsResponse.ts b/web/src/lib/api/generated/models/peopleCodexModelsResponse.ts new file mode 100644 index 000000000..ce4a87559 --- /dev/null +++ b/web/src/lib/api/generated/models/peopleCodexModelsResponse.ts @@ -0,0 +1,9 @@ +/** + * Generated by Orval. Do not edit manually. + */ +import type { CodexModel } from "./codexModel"; + +export interface PeopleCodexModelsResponse { + models: CodexModel[]; + [key: string]: unknown; +} diff --git a/web/src/lib/api/generated/models/peopleCodexProfileRequest.ts b/web/src/lib/api/generated/models/peopleCodexProfileRequest.ts new file mode 100644 index 000000000..b656d0b26 --- /dev/null +++ b/web/src/lib/api/generated/models/peopleCodexProfileRequest.ts @@ -0,0 +1,19 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export interface PeopleCodexProfileRequest { + allow_sensitive: boolean; + /** @minItems 1 */ + allowed_sources: string[]; + /** @minLength 1 */ + model: string; + /** @minLength 1 */ + reasoning_effort: string; + /** @minLength 1 */ + retention_posture: string; + source_since: string; + source_until?: string; + /** @minLength 1 */ + training_posture: string; +} diff --git a/web/src/lib/api/generated/models/peopleInferenceCheckResponse.ts b/web/src/lib/api/generated/models/peopleInferenceCheckResponse.ts new file mode 100644 index 000000000..0cbc9ab53 --- /dev/null +++ b/web/src/lib/api/generated/models/peopleInferenceCheckResponse.ts @@ -0,0 +1,12 @@ +/** + * Generated by Orval. Do not edit manually. + */ +import type { TokenUsage } from "./tokenUsage"; + +export interface PeopleInferenceCheckResponse { + fingerprint: string; + model: string; + ok: boolean; + usage: TokenUsage; + [key: string]: unknown; +} diff --git a/web/src/lib/api/generated/models/peopleInferenceConsentRequest.ts b/web/src/lib/api/generated/models/peopleInferenceConsentRequest.ts new file mode 100644 index 000000000..de784b7b2 --- /dev/null +++ b/web/src/lib/api/generated/models/peopleInferenceConsentRequest.ts @@ -0,0 +1,8 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export interface PeopleInferenceConsentRequest { + confirmed: boolean; + fingerprint: string; +} diff --git a/web/src/lib/api/generated/models/peopleInferenceKeyWriteRequest.ts b/web/src/lib/api/generated/models/peopleInferenceKeyWriteRequest.ts new file mode 100644 index 000000000..a521ae0cc --- /dev/null +++ b/web/src/lib/api/generated/models/peopleInferenceKeyWriteRequest.ts @@ -0,0 +1,8 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export interface PeopleInferenceKeyWriteRequest { + /** @minLength 1 */ + value: string; +} diff --git a/web/src/lib/api/generated/models/peopleInferencePresetCreateRequest.ts b/web/src/lib/api/generated/models/peopleInferencePresetCreateRequest.ts new file mode 100644 index 000000000..a15485b5f --- /dev/null +++ b/web/src/lib/api/generated/models/peopleInferencePresetCreateRequest.ts @@ -0,0 +1,19 @@ +/** + * Generated by Orval. Do not edit manually. + */ +import type { PeopleInferencePresetCreateRequestPresetId } from "./peopleInferencePresetCreateRequestPresetId"; + +export interface PeopleInferencePresetCreateRequest { + allow_sensitive: boolean; + /** @minItems 1 */ + allowed_sources: string[]; + /** @minLength 1 */ + model: string; + preset_id: PeopleInferencePresetCreateRequestPresetId; + /** @minLength 1 */ + retention_posture: string; + source_since: string; + source_until?: string; + /** @minLength 1 */ + training_posture: string; +} diff --git a/web/src/lib/api/generated/models/peopleInferencePresetCreateRequestPresetId.ts b/web/src/lib/api/generated/models/peopleInferencePresetCreateRequestPresetId.ts new file mode 100644 index 000000000..5edf507b0 --- /dev/null +++ b/web/src/lib/api/generated/models/peopleInferencePresetCreateRequestPresetId.ts @@ -0,0 +1,12 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export type PeopleInferencePresetCreateRequestPresetId = + (typeof PeopleInferencePresetCreateRequestPresetId)[keyof typeof PeopleInferencePresetCreateRequestPresetId]; + +export const PeopleInferencePresetCreateRequestPresetId = { + openai: "openai", + openrouter: "openrouter", + venice: "venice", +} as const; diff --git a/web/src/lib/api/generated/models/peopleInferenceProfileSetting.ts b/web/src/lib/api/generated/models/peopleInferenceProfileSetting.ts new file mode 100644 index 000000000..f9a19c0d4 --- /dev/null +++ b/web/src/lib/api/generated/models/peopleInferenceProfileSetting.ts @@ -0,0 +1,27 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export interface PeopleInferenceProfileSetting { + allow_sensitive: boolean; + allowed_sources: string[]; + checked: boolean; + consent_active: boolean; + credential_configured: boolean; + credential_env?: string; + credential_revision?: string; + credential_source: string; + endpoint?: string; + fingerprint?: string; + model: string; + name: string; + output_mode: string; + preset_id?: string; + protocol: string; + retention_posture: string; + selected: boolean; + source_since: string; + source_until?: string; + training_posture: string; + [key: string]: unknown; +} diff --git a/web/src/lib/api/generated/models/peopleInferenceSelectionRequest.ts b/web/src/lib/api/generated/models/peopleInferenceSelectionRequest.ts new file mode 100644 index 000000000..a5b7dd87a --- /dev/null +++ b/web/src/lib/api/generated/models/peopleInferenceSelectionRequest.ts @@ -0,0 +1,8 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export interface PeopleInferenceSelectionRequest { + /** @minLength 1 */ + name: string; +} diff --git a/web/src/lib/api/generated/models/peopleInferenceSettingsResponse.ts b/web/src/lib/api/generated/models/peopleInferenceSettingsResponse.ts new file mode 100644 index 000000000..b74e0da4e --- /dev/null +++ b/web/src/lib/api/generated/models/peopleInferenceSettingsResponse.ts @@ -0,0 +1,17 @@ +/** + * Generated by Orval. Do not edit manually. + */ +import type { PeopleInferenceProfileSetting } from "./peopleInferenceProfileSetting"; + +export interface PeopleInferenceSettingsResponse { + configured_enabled: boolean; + configured_fingerprint?: string; + configured_name?: string; + pending_restart: boolean; + profiles: PeopleInferenceProfileSetting[]; + running_enabled: boolean; + running_fingerprint?: string; + running_name?: string; + stored_credentials_supported: boolean; + [key: string]: unknown; +} diff --git a/web/src/lib/api/generated/models/putSettingsPeopleCodexProfilePathParameters.ts b/web/src/lib/api/generated/models/putSettingsPeopleCodexProfilePathParameters.ts new file mode 100644 index 000000000..87161afa9 --- /dev/null +++ b/web/src/lib/api/generated/models/putSettingsPeopleCodexProfilePathParameters.ts @@ -0,0 +1,7 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export type PutSettingsPeopleCodexProfilePathParameters = { + id: string; +}; diff --git a/web/src/lib/api/generated/models/putSettingsPeopleInferenceKeyPathParameters.ts b/web/src/lib/api/generated/models/putSettingsPeopleInferenceKeyPathParameters.ts new file mode 100644 index 000000000..539f582ae --- /dev/null +++ b/web/src/lib/api/generated/models/putSettingsPeopleInferenceKeyPathParameters.ts @@ -0,0 +1,7 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export type PutSettingsPeopleInferenceKeyPathParameters = { + name: string; +}; diff --git a/web/src/lib/api/generated/models/putSettingsPeopleInferencePresetPathParameters.ts b/web/src/lib/api/generated/models/putSettingsPeopleInferencePresetPathParameters.ts new file mode 100644 index 000000000..50ed2d856 --- /dev/null +++ b/web/src/lib/api/generated/models/putSettingsPeopleInferencePresetPathParameters.ts @@ -0,0 +1,7 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export type PutSettingsPeopleInferencePresetPathParameters = { + name: string; +}; diff --git a/web/src/lib/api/generated/models/revokeSettingsPeopleInferenceProviderPathParameters.ts b/web/src/lib/api/generated/models/revokeSettingsPeopleInferenceProviderPathParameters.ts new file mode 100644 index 000000000..b2ecb8d03 --- /dev/null +++ b/web/src/lib/api/generated/models/revokeSettingsPeopleInferenceProviderPathParameters.ts @@ -0,0 +1,7 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export type RevokeSettingsPeopleInferenceProviderPathParameters = { + name: string; +}; diff --git a/web/src/lib/api/generated/models/tokenUsage.ts b/web/src/lib/api/generated/models/tokenUsage.ts new file mode 100644 index 000000000..ab46d72bb --- /dev/null +++ b/web/src/lib/api/generated/models/tokenUsage.ts @@ -0,0 +1,9 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export interface TokenUsage { + input_tokens: number; + output_tokens: number; + [key: string]: unknown; +} diff --git a/web/src/lib/components/settings/PeopleInferenceSettings.svelte b/web/src/lib/components/settings/PeopleInferenceSettings.svelte new file mode 100644 index 000000000..b79818c4b --- /dev/null +++ b/web/src/lib/components/settings/PeopleInferenceSettings.svelte @@ -0,0 +1,199 @@ + + +
+
+

People sweep

+

Choose the provider used for people sweep and briefs. A synthetic check and your consent are required before use.

+
+ + {#if controller.loading} +

Loading people sweep settings…

+ {:else if !controller.status} +

{controller.error || 'Unable to load people sweep settings.'}

+ {:else} + {#if controller.error}{/if} + {#if controller.status.pending_restart} +

Restart the daemon to use the saved people sweep profile. The running daemon still uses {controller.status.running_name || 'no profile'}.

+ {/if} +
+

Configured: {controller.status.configured_name || 'None'} ({controller.status.configured_enabled ? 'enabled' : 'disabled'})

+

Running: {controller.status.running_name || 'None'} ({controller.status.running_enabled ? 'enabled' : 'disabled'})

+ {#if controller.status.configured_enabled} +
+ + {#if controller.status.stored_credentials_supported} +
{ event.preventDefault(); void createProfile(); }}> +

Add a profile

+ { provider = value as typeof provider; key = ''; }} /> + + + +
+ Archive source classes + setSource('conversation_text', checked)} /> + setSource('meeting_text', checked)} /> + setSource('document_text', checked)} /> +
+ + +
+ Sensitive archive content + + +

Real sweeps send archive text to this provider. Excluding sensitive content permits only the synthetic check.

+
+ + +
+ {/if} + {/if} + + {/if} + {/if} + + + diff --git a/web/src/lib/components/settings/PeopleInferenceSettings.test.ts b/web/src/lib/components/settings/PeopleInferenceSettings.test.ts new file mode 100644 index 000000000..5ea3e80dd --- /dev/null +++ b/web/src/lib/components/settings/PeopleInferenceSettings.test.ts @@ -0,0 +1,291 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; +import { createAPIClient } from '../../api/client'; +import type { PeopleInferenceProfileSetting, PeopleInferenceSettingsResponse } from '../../api/generated/models'; +import { PeopleInferenceController } from '../../settings/people-inference-controller.svelte'; +import PeopleInferenceSettings from './PeopleInferenceSettings.svelte'; + +const profile: PeopleInferenceProfileSetting = { + name: 'routed', preset_id: 'openrouter', protocol: 'openai-chat', model: 'model-one', + endpoint: 'https://openrouter.example.test/api/v1', credential_source: 'stored', + credential_configured: true, credential_revision: '"credential-a"', checked: false, + consent_active: false, fingerprint: 'fingerprint-routed', selected: false, + output_mode: 'strict_schema', allowed_sources: ['conversation_text'], source_since: '2025-01-01', + allow_sensitive: true, retention_posture: 'No retention', training_posture: 'No training', +}; +const status: PeopleInferenceSettingsResponse = { + stored_credentials_supported: true, profiles: [profile], configured_enabled: false, running_enabled: false, pending_restart: false, +}; +const preset = { + preset_id: 'openrouter' as const, model: 'model-one', allowed_sources: ['conversation_text'], + source_since: '2025-01-01', allow_sensitive: true, retention_posture: 'No retention', training_posture: 'No training', +}; +function fixture(initial = status, respond?: (request: Request) => Response | Promise | undefined) { + let current = structuredClone(initial); + const requests: Request[] = []; + const client = createAPIClient(vi.fn(async (input) => { + const request = input as Request; + requests.push(request.clone()); + const custom = await respond?.(request.clone()); + if (custom) return custom; + const path = new URL(request.url).pathname; + if (request.method === 'GET') return Response.json(current, { headers: { ETag: '"config-a"' } }); + if (path.endsWith('/check')) { + current = { ...current, profiles: current.profiles.map((item) => ({ ...item, checked: true })) }; + return Response.json({ ok: true, fingerprint: 'fingerprint-routed' }); + } + if (path.endsWith('/consent')) { + current = { ...current, profiles: current.profiles.map((item) => ({ ...item, consent_active: true })) }; + } else if (path.endsWith('/revoke')) { + current = { ...current, profiles: current.profiles.map((item) => ({ ...item, consent_active: false })) }; + } else if (path.endsWith('/select')) { + current = { ...current, configured_name: 'routed', configured_enabled: true, pending_restart: true }; + } else if (path.endsWith('/disable')) { + current = { ...current, configured_enabled: false, pending_restart: true }; + } else if (path.endsWith('/key')) { + current = { ...current, profiles: current.profiles.map((item) => ({ ...item, + credential_revision: '"credential-b"', credential_configured: true, checked: false, consent_active: false })) }; + } else if (request.method === 'DELETE') { + current = { ...current, profiles: current.profiles.filter((item) => !path.endsWith(`/${item.name}`)) }; + } else if (request.method === 'PUT' && path.endsWith('/routed')) { + current = { ...current, profiles: [{ ...profile, credential_configured: false }] }; + } else throw new Error(`Unexpected request: ${request.method} ${path}`); + return Response.json(current, { headers: { ETag: '"config-b"' } }); + })); + return { client, requests }; +} + +async function checkAndConsent(controller: PeopleInferenceController): Promise { + await controller.check(); + controller.disclosureConfirmed = true; + await controller.consent(); +} + +describe('PeopleInferenceSettings', () => { + it('hides stored-key enrollment on unsupported hosts while retaining host-managed profiles', async () => { + const { client, requests } = fixture({ ...status, stored_credentials_supported: false, + profiles: [{ ...profile, credential_source: 'env', credential_env: 'PEOPLE_API_KEY' }] }); + render(PeopleInferenceSettings, { client }); + await screen.findByText(/Configure an environment credential with/); + expect(screen.getByText('msgvault person provider add')).toBeDefined(); + expect(screen.getByText('--credential-env')).toBeDefined(); + expect(screen.getByText(/on the daemon host/)).toBeDefined(); + expect(screen.queryByRole('button', { name: 'Create profile' })).toBeNull(); + expect(screen.queryByLabelText('API key')).toBeNull(); + expect(screen.queryByLabelText('Replacement API key')).toBeNull(); + await fireEvent.click(screen.getByRole('button', { name: 'Check provider' })); + await screen.findByRole('region', { name: 'Archive disclosure' }); + expect(requests.some((request) => request.url.endsWith('/check'))).toBe(true); + expect(requests.some((request) => request.method === 'PUT')).toBe(false); + }); + + it('hides replacement keys for saved stored-key profiles on unsupported hosts', async () => { + const { client } = fixture({ ...status, stored_credentials_supported: false, + profiles: [{ ...profile, credential_configured: false, credential_revision: undefined }] }); + render(PeopleInferenceSettings, { client }); + await screen.findByText(/Configure an environment credential with/); + expect(screen.queryByLabelText('Replacement API key')).toBeNull(); + expect((screen.getByRole('button', { name: 'Check provider' }) as HTMLButtonElement).disabled).toBe(true); + }); + + it('keeps a newly saved profile visible when saving its key fails and lets the user retry', async () => { + let failKey = true; + const { client, requests } = fixture({ ...status, profiles: [] }, (request) => { + if (request.url.endsWith('/key') && failKey) return Response.json({ message: 'Credential store unavailable' }, { status: 500 }); + }); + const controller = new PeopleInferenceController(client); + await controller.load(); + await controller.create('routed', preset, 'synthetic-secret'); + expect(controller.error).toBe('Credential store unavailable'); + expect(controller.selectedProfile?.name).toBe('routed'); + expect(controller.selectedProfile?.credential_configured).toBe(false); + expect(requests[1]!.headers.get('If-Match')).toBe('"config-a"'); + expect(requests[2]!.headers.get('If-Match')).toBe('"credential-a"'); + await expect(requests[1]!.json()).resolves.toEqual(preset); + failKey = false; + await controller.saveKey('synthetic-replacement'); + expect(controller.error).toBe(''); + expect(controller.selectedProfile?.credential_configured).toBe(true); + }); + + it('requires the checked disclosure and consent before enabling, and shows saved versus running state', async () => { + const { client, requests } = fixture(); + render(PeopleInferenceSettings, { client }); + await screen.findByText('Stored key'); + expect((screen.getByRole('button', { name: 'Select and enable' }) as HTMLButtonElement).disabled).toBe(true); + await fireEvent.click(screen.getByRole('button', { name: 'Check provider' })); + await screen.findByText('Retention: No retention'); + expect((screen.getByRole('button', { name: 'Grant consent' }) as HTMLButtonElement).disabled).toBe(true); + await fireEvent.click(screen.getByLabelText('I confirm this exact disclosure')); + await fireEvent.click(screen.getByRole('button', { name: 'Grant consent' })); + await waitFor(() => expect((screen.getByRole('button', { name: 'Select and enable' }) as HTMLButtonElement).disabled).toBe(false)); + await fireEvent.click(screen.getByRole('button', { name: 'Select and enable' })); + expect((await screen.findByRole('status')).textContent).toContain('Restart the daemon'); + const consent = requests.find((request) => request.url.endsWith('/consent'))!; + await expect(consent.json()).resolves.toEqual({ fingerprint: 'fingerprint-routed', confirmed: true }); + expect(consent.headers.get('If-Match')).toBe('"config-a"'); + expect(requests.at(-1)!.headers.get('If-Match')).toBe('"config-b"'); + }); + + it('submits explicit archive policy with a write-only API key through the generated client', async () => { + const { client, requests } = fixture({ ...status, profiles: [] }); + render(PeopleInferenceSettings, { client }); + await screen.findByLabelText('Profile name'); + await fireEvent.click(screen.getByRole('combobox', { name: /^Provider:/ })); + await fireEvent.click(screen.getByRole('option', { name: 'OpenRouter' })); + await fireEvent.input(screen.getByLabelText('Profile name'), { target: { value: 'routed' } }); + await fireEvent.input(screen.getByLabelText('Model ID'), { target: { value: 'model-one' } }); + await fireEvent.input(screen.getByLabelText('API key'), { target: { value: 'synthetic-secret' } }); + await fireEvent.click(screen.getByLabelText('Conversation text')); + await fireEvent.input(screen.getByLabelText('Archive data since (YYYY-MM-DD)'), { target: { value: '2025-01-01' } }); + await fireEvent.input(screen.getByLabelText('Retention statement'), { target: { value: 'No retention' } }); + await fireEvent.input(screen.getByLabelText('Training statement'), { target: { value: 'No training' } }); + await fireEvent.click(screen.getByLabelText('Allow sensitive content')); + await fireEvent.click(screen.getByRole('button', { name: 'Create profile' })); + await screen.findByText('Stored key'); + await expect(requests[1]!.json()).resolves.toEqual(preset); + await expect(requests[2]!.json()).resolves.toEqual({ value: 'synthetic-secret' }); + expect((screen.getByLabelText('API key') as HTMLInputElement).value).toBe(''); + }); + + it('clears the checked disclosure when a repeat check fails and shows the daemon error', async () => { + let failCheck = false; + const { client } = fixture(status, (request) => { + if (request.url.endsWith('/check') && failCheck) return Response.json({ message: 'Provider rejected the model' }, { status: 422 }); + }); + const controller = new PeopleInferenceController(client); + await controller.load(); + await checkAndConsent(controller); + expect(controller.canSelect).toBe(true); + failCheck = true; + await controller.check(); + expect(controller.canSelect).toBe(false); + expect(controller.checkedProfile).toBeUndefined(); + expect(controller.error).toBe('Provider rejected the model'); + }); + + it('requires renewed disclosure confirmation even when saved consent is active', async () => { + const { client } = fixture({ ...status, profiles: [{ ...profile, consent_active: true }] }); + const controller = new PeopleInferenceController(client); + await controller.load(); + await controller.check(); + expect(controller.canSelect).toBe(false); + controller.disclosureConfirmed = true; + await controller.consent(); + expect(controller.canSelect).toBe(true); + }); + + it('rejects a check when the refreshed fingerprint differs', async () => { + let reads = 0; + const { client } = fixture(status, (request) => { + if (request.method === 'GET' && ++reads > 1) return Response.json({ ...status, + profiles: [{ ...profile, fingerprint: 'changed' }] }, { headers: { ETag: '"config-b"' } }); + }); + const controller = new PeopleInferenceController(client); + await controller.load(); + await controller.check(); + expect(controller.checkedProfile).toBeUndefined(); + expect(controller.error).toContain('changed after the check'); + }); + + it('drops a disclosure when selection changes while the check is running', async () => { + let finish!: (response: Response) => void; + const checked = new Promise((resolve) => { finish = resolve; }); + const { client } = fixture({ ...status, profiles: [profile, { ...profile, name: 'spare' }] }, (request) => + request.url.endsWith('/check') ? checked : undefined); + const controller = new PeopleInferenceController(client); + await controller.load(); + const checking = controller.check(); + controller.choose('spare'); + finish(Response.json({ ok: true, fingerprint: 'fingerprint-routed' })); + await checking; + expect(controller.selectedName).toBe('spare'); + expect(controller.checkedProfile).toBeUndefined(); + }); + + it('requires reload after a config conflict or a missing ETag', async () => { + let missingETag = false; + const { client, requests } = fixture(status, (request) => { + if (request.method !== 'GET') return Response.json({ message: 'Stale settings' }, { status: 412 }); + if (missingETag) return Response.json(status); + }); + const controller = new PeopleInferenceController(client); + await controller.load(); + await controller.disable(); + expect(controller.error).toContain('changed on disk'); + await controller.disable(); + expect(controller.error).toContain('Reload'); + expect(requests).toHaveLength(2); + missingETag = true; + await controller.load(); + expect(controller.error).toContain('config ETag'); + await controller.disable(); + expect(requests).toHaveLength(3); + }); + + it('uses credential revisions for key replacement and clears checked consent', async () => { + const { client, requests } = fixture(); + const controller = new PeopleInferenceController(client); + await controller.load(); + await checkAndConsent(controller); + await controller.saveKey('synthetic-secret'); + expect(controller.canSelect).toBe(false); + await controller.saveKey('synthetic-replacement'); + const keys = requests.filter((request) => request.url.endsWith('/key')); + expect(keys.map((request) => request.headers.get('If-Match'))).toEqual(['"credential-a"', '"credential-b"']); + }); + + it('requires reload after a credential conflict without invalidating config edits', async () => { + const { client, requests } = fixture(status, (request) => { + if (request.url.endsWith('/key')) return Response.json({ message: 'Stale key' }, { status: 412 }); + }); + const controller = new PeopleInferenceController(client); + await controller.load(); + await controller.saveKey('synthetic-secret'); + expect(controller.error).toContain('credential changed'); + await controller.saveKey('synthetic-replacement'); + expect(requests).toHaveLength(2); + await controller.disable(); + expect(requests.at(-1)!.headers.get('If-Match')).toBe('"config-a"'); + }); + + it('does not enable a profile if consent is missing from the server response', async () => { + const { client } = fixture(status, (request) => { + if (request.url.endsWith('/consent')) return Response.json(status, { headers: { ETag: '"config-a"' } }); + }); + const controller = new PeopleInferenceController(client); + await controller.load(); + await checkAndConsent(controller); + expect(controller.canSelect).toBe(false); + expect(controller.error).toContain('Consent was not recorded'); + }); + + it('shows host-managed credentials and unavailable Codex profiles without actionable checks', async () => { + const { client } = fixture({ ...status, profiles: [{ ...profile, + credential_source: 'env', credential_env: 'PEOPLE_API_KEY', credential_configured: false }] }); + const rendered = render(PeopleInferenceSettings, { client }); + await screen.findByText('Set PEOPLE_API_KEY on daemon host'); + expect((screen.getByRole('button', { name: 'Check provider' }) as HTMLButtonElement).disabled).toBe(true); + rendered.unmount(); + const codex = fixture({ ...status, profiles: [{ ...profile, protocol: 'codex_app_server' }] }); + render(PeopleInferenceSettings, { client: codex.client }); + await screen.findByText('Codex is unavailable in this release'); + expect((screen.getByRole('button', { name: 'Check provider' }) as HTMLButtonElement).disabled).toBe(true); + }); + + it('revokes consent, disables sweeps, and confirms removal even for the final inactive profile', async () => { + const { client, requests } = fixture({ ...status, configured_name: 'routed', configured_enabled: true, + running_name: 'routed', running_enabled: true, profiles: [{ ...profile, consent_active: true }] }); + render(PeopleInferenceSettings, { client }); + await screen.findByText('Granted for this profile'); + await fireEvent.click(screen.getByRole('button', { name: 'Revoke consent' })); + await screen.findByText('Not granted'); + await fireEvent.click(screen.getByRole('button', { name: 'Disable people sweep' })); + await screen.findByRole('status'); + await fireEvent.click(screen.getByRole('button', { name: 'Remove profile' })); + expect(requests.some((request) => request.method === 'DELETE')).toBe(false); + await fireEvent.click(screen.getByRole('button', { name: 'Confirm removal' })); + await waitFor(() => expect(screen.queryByText('Profile setup')).toBeNull()); + expect(requests.at(-1)!.method).toBe('DELETE'); + }); +}); diff --git a/web/src/lib/components/settings/SettingsWorkspace.svelte b/web/src/lib/components/settings/SettingsWorkspace.svelte index 88463ddfc..f9633bf73 100644 --- a/web/src/lib/components/settings/SettingsWorkspace.svelte +++ b/web/src/lib/components/settings/SettingsWorkspace.svelte @@ -47,6 +47,7 @@ } from '../../api/generated/models'; import type { CardDAVSettingsRequest, SettingsNavigationTarget } from '../../carddav/navigation'; import CardDAVSettingsWorkspace from './CardDAVSettingsWorkspace.svelte'; + import PeopleInferenceSettings from './PeopleInferenceSettings.svelte'; import CronField from './CronField.svelte'; import PersonEnrichmentProviderCard from './PersonEnrichmentProviderCard.svelte'; import PersonEnrichmentProviderCreator from './PersonEnrichmentProviderCreator.svelte'; @@ -109,6 +110,7 @@ const categories: SettingsCategory[] = $derived([ ...settingsGroups.map((group) => ({ id: group.id, label: group.label })), { id: 'carddav', label: 'CardDAV account' }, + { id: 'people', label: 'People sweep' }, ]); const dirtyCount = $derived(Object.keys(drafts).length + Object.keys(secretUpdates).length); // An emptied number field is a draft in progress, not a value: it keeps the @@ -612,7 +614,7 @@ {categories} bind:active={activeCategory} title="Settings" - footer={activeCategory === 'carddav' ? undefined : settingsFooter} + footer={activeCategory === 'carddav' || activeCategory === 'people' ? undefined : settingsFooter} > {#snippet panel(activeId)}
@@ -638,6 +640,8 @@ {onCardDAVRequestConsumed} onSettingsRefresh={() => loadSettings(true)} /> + {:else if activeId === 'people'} + {:else} {#each settingsGroups.filter((candidate) => candidate.id === activeId) as group (group.id)} {@const posture = restartPosture(group.settings)} diff --git a/web/src/lib/components/settings/SettingsWorkspace.test.ts b/web/src/lib/components/settings/SettingsWorkspace.test.ts index cc491e7c2..9ad59408e 100644 --- a/web/src/lib/components/settings/SettingsWorkspace.test.ts +++ b/web/src/lib/components/settings/SettingsWorkspace.test.ts @@ -49,6 +49,179 @@ const initialSettings = { afterEach(() => vi.useRealTimers()); describe('SettingsWorkspace', () => { + it('reads a host-configured environment profile and waits for daemon credentials before checking', async () => { + const requests: Request[] = []; + const created = true; + let configured = false; + const revision = 1; + const status = () => Response.json({ + stored_credentials_supported: true, + profiles: created ? [{ + name: 'from-env', preset_id: 'openrouter', protocol: 'openai-chat', model: 'model-one', + endpoint: 'https://openrouter.example.test/api/v1', credential_source: 'env', + credential_env: 'PEOPLE_API_KEY', credential_configured: configured, + checked: false, consent_active: false, fingerprint: 'env-fingerprint', selected: false, + output_mode: 'strict_schema', allowed_sources: ['conversation_text'], source_since: '2025-01-01', + allow_sensitive: false, retention_posture: 'Operator assertion: no retention', + training_posture: 'Operator assertion: no training', + }] : [], configured_enabled: false, running_enabled: false, pending_restart: created, + }, { headers: { ETag: `"env-config-${revision}"` } }); + const fetchFn = vi.fn(async (input) => { + const request = input as Request; + requests.push(request); + const path = new URL(request.url).pathname; + if (path === '/api/v1/settings') return settingsResponse(initialSettings, '"settings-a"'); + if (request.method === 'GET' && path === '/api/v1/settings/people-inference') return status(); + if (request.method === 'POST' && path.endsWith('/providers/from-env/check')) { + return Response.json({ ok: true, fingerprint: 'env-fingerprint', model: 'model-one', usage: {} }); + } + throw new Error(`Unexpected request: ${request.method} ${path}`); + }); + render(SettingsWorkspace, { client: createAPIClient(fetchFn) }); + await openSettingsCategory('People sweep'); + await screen.findByRole('heading', { name: 'Add a profile' }); + expect(await screen.findByText('Set PEOPLE_API_KEY on daemon host')).toBeDefined(); + expect(screen.queryByLabelText('Replacement API key')).toBeNull(); + expect((screen.getByRole('button', { name: 'Check provider' }) as HTMLButtonElement).disabled).toBe(true); + expect(requests.some((request) => request.url.endsWith('/providers/from-env/check'))).toBe(false); + configured = true; + await fireEvent.click(screen.getByRole('button', { name: 'Reload people sweep settings' })); + expect(await screen.findByText('Environment PEOPLE_API_KEY ready')).toBeDefined(); + await fireEvent.click(screen.getByRole('button', { name: 'Check provider' })); + expect(await screen.findByText('Retention: Operator assertion: no retention')).toBeDefined(); + expect(requests.some((request) => request.method === 'PUT' && request.url.endsWith('/key'))).toBe(false); + expect(document.body.textContent).not.toContain('synthetic-secret'); + }, 15000); + + it('shows a failed people settings read without an actionable setup form', async () => { + const fetchFn = vi.fn(async (input) => { + const request = input as Request; + if (new URL(request.url).pathname === '/api/v1/settings') return settingsResponse(initialSettings, '"settings-a"'); + return Response.json({ message: 'People inference unavailable on this daemon' }, { status: 404 }); + }); + render(SettingsWorkspace, { client: createAPIClient(fetchFn) }); + await openSettingsCategory('People sweep'); + expect(await screen.findByRole('alert')).toBeDefined(); + expect(screen.queryByRole('button', { name: 'Create profile' })).toBeNull(); + }); + + it('creates, checks, consents, and selects an HTTP profile through generated settings requests', async () => { + const requests: Request[] = []; + let revision = 1; + let stored = false; + let checked = false; + let consented = false; + let selected = false; + const profile = () => ({ + name: 'routed', preset_id: 'openrouter', protocol: 'openai-chat', model: 'model-one', + endpoint: 'https://openrouter.example.test/api/v1', credential_source: 'stored', + credential_configured: stored, credential_revision: '"credential-a"', + checked, consent_active: consented, fingerprint: stored ? 'fingerprint-key' : 'fingerprint-new', + selected, output_mode: 'strict_schema', allowed_sources: ['conversation_text'], + source_since: '2025-01-01', allow_sensitive: true, + retention_posture: 'No retention', training_posture: 'No training', + }); + const status = () => Response.json({ + stored_credentials_supported: true, + profiles: revision > 1 ? [profile()] : [], configured_enabled: selected, + configured_name: selected ? 'routed' : undefined, running_enabled: false, + pending_restart: selected, + }, { headers: { ETag: `"config-${revision}"` } }); + const fetchFn = vi.fn(async (input) => { + const request = input as Request; + requests.push(request); + const path = new URL(request.url).pathname; + if (path === '/api/v1/settings') return settingsResponse(initialSettings, '"settings-a"'); + if (path === '/api/v1/settings/people-inference') return status(); + if (request.method === 'PUT' && path === '/api/v1/settings/people-inference/providers/routed') { + revision += 1; + return status(); + } + if (request.method === 'PUT' && path.endsWith('/providers/routed/key')) { + stored = true; + return status(); + } + if (request.method === 'POST' && path.endsWith('/providers/routed/check')) { + checked = true; + return Response.json({ ok: true, fingerprint: 'fingerprint-key', model: 'model-one', usage: {} }); + } + if (request.method === 'POST' && path.endsWith('/providers/routed/consent')) { + consented = true; + revision += 1; + return status(); + } + if (request.method === 'POST' && path.endsWith('/people-inference/select')) { + selected = true; + revision += 1; + return status(); + } + throw new Error(`Unexpected request: ${request.method} ${path}`); + }); + render(SettingsWorkspace, { client: createAPIClient(fetchFn) }); + await openSettingsCategory('People sweep'); + await screen.findByRole('heading', { name: 'Add a profile' }); + await chooseSelectOption(screen.getByRole('combobox', { name: 'Provider: OpenAI Platform' }), 'OpenRouter'); + await fireEvent.input(screen.getByLabelText('Profile name'), { target: { value: 'routed' } }); + await fireEvent.input(screen.getByLabelText('Model ID'), { target: { value: 'model-one' } }); + await fireEvent.input(screen.getByLabelText('API key'), { target: { value: 'synthetic-secret' } }); + await fireEvent.click(screen.getByLabelText('Conversation text')); + await fireEvent.input(screen.getByLabelText(/^Archive data since/), { target: { value: '2025-01-01' } }); + await fireEvent.input(screen.getByLabelText('Retention statement'), { target: { value: 'No retention' } }); + await fireEvent.input(screen.getByLabelText('Training statement'), { target: { value: 'No training' } }); + await fireEvent.click(screen.getByLabelText('Allow sensitive content')); + await fireEvent.click(screen.getByRole('button', { name: 'Create profile' })); + expect(await screen.findByText('Stored key')).toBeDefined(); + await waitFor(() => expect((screen.getByRole('button', { name: 'Check provider' }) as HTMLButtonElement).disabled).toBe(false)); + await fireEvent.click(screen.getByRole('button', { name: 'Check provider' })); + expect(await screen.findByText('Retention: No retention')).toBeDefined(); + await fireEvent.click(screen.getByLabelText('I confirm this exact disclosure')); + await fireEvent.click(screen.getByRole('button', { name: 'Grant consent' })); + await waitFor(() => expect((screen.getByRole('button', { name: 'Select and enable' }) as HTMLButtonElement).disabled).toBe(false)); + await fireEvent.click(screen.getByRole('button', { name: 'Select and enable' })); + expect(await screen.findByText(/Restart the daemon to use the saved/)).toBeDefined(); + expect(document.body.textContent).not.toContain('synthetic-secret'); + const create = requests.find((item) => item.method === 'PUT' && item.url.endsWith('/providers/routed'))!; + expect(create.headers.get('If-Match')).toBe('"config-1"'); + await expect(create.clone().json()).resolves.toMatchObject({ preset_id: 'openrouter', + allowed_sources: ['conversation_text'], source_since: '2025-01-01', allow_sensitive: true }); + const key = requests.find((item) => item.url.endsWith('/providers/routed/key'))!; + expect(key.headers.get('If-Match')).toBe('"credential-a"'); + expect(requests.find((item) => item.url.endsWith('/providers/routed/check'))?.headers.get('If-Match')).toBe('"config-2"'); + expect(requests.find((item) => item.url.endsWith('/providers/routed/consent'))?.headers.get('If-Match')).toBe('"config-2"'); + expect(requests.find((item) => item.url.endsWith('/people-inference/select'))?.headers.get('If-Match')).toBe('"config-3"'); + }, 15000); + + it('opens server-backed people sweep setup with supported provider presets', async () => { + const fetchFn = vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + const path = new URL(request.url).pathname; + if (path === '/api/v1/settings') return settingsResponse(initialSettings, '"settings-a"'); + if (path === '/api/v1/settings/people-inference') return Response.json({ + stored_credentials_supported: true, + profiles: [{ + name: 'routed', preset_id: 'openrouter', protocol: 'openai_chat', + model: 'model-one', endpoint: 'https://openrouter.example.test/api/v1', + credential_source: 'stored', credential_configured: true, + credential_revision: '"credential-a"', checked: false, consent_active: false, + fingerprint: 'fingerprint-routed', selected: false, output_mode: 'strict_schema', + retention_posture: 'Operator assertion: no retention', + training_posture: 'Operator assertion: no training', + allowed_sources: ['conversation_text'], source_since: '2025-01-01', allow_sensitive: true, + }], + configured_enabled: false, running_enabled: false, pending_restart: false, + }, { headers: { ETag: '"people-a"' } }); + throw new Error(`Unexpected request: ${request.method} ${path}`); + }); + render(SettingsWorkspace, { client: createAPIClient(fetchFn) }); + + await openSettingsCategory('People sweep'); + expect(await screen.findByRole('heading', { name: 'People sweep' })).toBeDefined(); + expect(await screen.findByText('Stored key')).toBeDefined(); + await fireEvent.click(screen.getByRole('combobox', { name: /^Provider:/ })); + expect(screen.getAllByRole('option').map((option) => option.textContent?.trim())).toEqual(['OpenAI Platform', 'OpenRouter', 'Venice']); + expect(fetchFn.mock.calls.some(([input]) => new URL((input as Request).url).pathname === '/api/v1/settings/people-inference')).toBe(true); + }); + it.each([ [{ authority: 'document_index', categoryID: 'archive', settingKey: 'analytics.auto_build_cache' }, 'Archive'], [{ authority: 'document_vector', categoryID: 'search', settingKey: 'vector.enabled' }, 'Search'], diff --git a/web/src/lib/settings/people-inference-controller.svelte.ts b/web/src/lib/settings/people-inference-controller.svelte.ts new file mode 100644 index 000000000..34c351f72 --- /dev/null +++ b/web/src/lib/settings/people-inference-controller.svelte.ts @@ -0,0 +1,214 @@ +import type { APIClient } from '../api/client'; +import type { APIResponse } from '../api/runtime'; +import { + checkSettingsPeopleInferenceProvider, + consentSettingsPeopleInferenceProvider, + deleteSettingsPeopleInferenceProvider, + disableSettingsPeopleInference, + getSettingsPeopleInference, + putSettingsPeopleInferenceKey, + putSettingsPeopleInferencePreset, + revokeSettingsPeopleInferenceProvider, + selectSettingsPeopleInference, +} from '../api/generated/api/api'; +import type { + PeopleInferencePresetCreateRequest, + PeopleInferenceProfileSetting, + PeopleInferenceSettingsResponse, +} from '../api/generated/models'; + +export class PeopleInferenceController { + status = $state(); + selectedName = $state(''); + loading = $state(true); + busy = $state(false); + error = $state(''); + checkedProfile = $state(); + disclosureConfirmed = $state(false); + private configETag = ''; + private consentedFingerprint = $state(''); + private destroyed = false; + private selectionEpoch = 0; + + constructor(private readonly client: APIClient) {} + + get selectedProfile(): PeopleInferenceProfileSetting | undefined { + return this.status?.profiles.find((profile) => profile.name === this.selectedName); + } + + get canConsent(): boolean { + return Boolean(this.disclosureConfirmed && this.checkedProfile?.fingerprint && + this.checkedProfile.fingerprint === this.selectedProfile?.fingerprint); + } + + get canSelect(): boolean { + return Boolean(this.checkedProfile?.fingerprint && + this.checkedProfile.fingerprint === this.selectedProfile?.fingerprint && + this.selectedProfile?.consent_active && this.consentedFingerprint === this.checkedProfile.fingerprint); + } + + async load(): Promise { + this.loading = true; + this.clearCheck(); + await this.run(async () => { + this.capture(await getSettingsPeopleInference(this.client)); + if (!this.status?.profiles.some((profile) => profile.name === this.selectedName)) { + this.choose(this.status?.configured_name ?? this.status?.profiles[0]?.name ?? ''); + } + }); + if (!this.destroyed) this.loading = false; + } + + choose(name: string): void { + this.selectionEpoch += 1; + this.selectedName = name; + this.clearCheck(); + } + + async create(name: string, request: PeopleInferencePresetCreateRequest, key: string): Promise { + await this.run(async () => { + this.capture(await putSettingsPeopleInferencePreset({ name }, request, this.configOptions())); + if (this.destroyed) return; + // Creating a profile and storing its key are separate writes. Keep the + // saved profile visible so a failed key write can be retried in place. + this.choose(name); + if (key) await this.writeKey(name, key); + }); + } + + async saveKey(key: string): Promise { + if (!this.selectedName || !key) return; + this.clearCheck(); + await this.run(() => this.writeKey(this.selectedName, key)); + } + + private async writeKey(name: string, key: string): Promise { + const profile = this.status?.profiles.find((item) => item.name === name); + if (!profile?.credential_revision) throw new Error('Reload people sweep settings before replacing the key.'); + const result = await putSettingsPeopleInferenceKey({ name }, { value: key }, { + ...this.client, headers: { 'If-Match': profile.credential_revision }, + }); + if (result.response.status === 412) { + profile.credential_revision = undefined; + throw new Error('People provider credential changed. Reload people sweep settings before replacing its key.'); + } + this.capture(result); + } + + async check(): Promise { + const profile = this.selectedProfile; + const selectionEpoch = this.selectionEpoch; + if (!profile) return; + this.clearCheck(); + await this.run(async () => { + const result = this.read(await checkSettingsPeopleInferenceProvider({ name: profile.name }, this.configOptions())); + const refreshed = await getSettingsPeopleInference(this.client); + this.capture(refreshed); + if (this.destroyed || this.selectionEpoch !== selectionEpoch) return; + if (!result.ok || !result.fingerprint || result.fingerprint !== profile.fingerprint || + result.fingerprint !== this.selectedProfile?.fingerprint) { + throw new Error('This profile changed after the check. Reload and check again.'); + } + this.checkedProfile = this.selectedProfile; + }); + } + + async consent(): Promise { + if (!this.canConsent) return; + const name = this.selectedName; + const fingerprint = this.checkedProfile!.fingerprint!; + await this.run(async () => { + this.capture(await consentSettingsPeopleInferenceProvider({ name }, { + fingerprint, confirmed: true, + }, this.configOptions())); + const profile = this.status?.profiles.find((item) => item.name === name); + if (profile?.fingerprint !== fingerprint || !profile.consent_active) { + this.clearCheck(); + throw new Error('Consent was not recorded for the checked profile. Reload and try again.'); + } + this.consentedFingerprint = fingerprint; + }); + } + + async select(): Promise { + if (!this.canSelect) return; + await this.run(async () => { + this.capture(await selectSettingsPeopleInference({ name: this.selectedName }, this.configOptions())); + }); + } + + async revoke(): Promise { + if (!this.selectedProfile?.consent_active) return; + this.clearCheck(); + await this.run(async () => { + this.capture(await revokeSettingsPeopleInferenceProvider({ name: this.selectedName }, this.configOptions())); + }); + } + + async disable(): Promise { + this.clearCheck(); + await this.run(async () => { + this.capture(await disableSettingsPeopleInference(this.configOptions())); + }); + } + + async remove(name: string): Promise { + this.clearCheck(); + await this.run(async () => { + this.capture(await deleteSettingsPeopleInferenceProvider({ name }, this.configOptions())); + if (!this.destroyed) this.choose(this.status?.profiles[0]?.name ?? ''); + }); + } + + destroy(): void { this.destroyed = true; } + + private clearCheck(): void { + this.checkedProfile = undefined; + this.disclosureConfirmed = false; + this.consentedFingerprint = ''; + } + + private configOptions() { + if (!this.configETag) throw new Error('Reload people sweep settings before editing.'); + return { ...this.client, headers: { 'If-Match': this.configETag } }; + } + + private read(result: APIResponse): T { + if (result.response.status === 412) { + this.configETag = ''; + this.clearCheck(); + throw new Error('People sweep settings changed on disk. Reload and review your changes before trying again.'); + } + if (!result.data) throw new Error(result.error?.message || 'Unable to update people sweep settings.'); + return result.data; + } + + private capture(result: APIResponse): void { + const status = this.read(result); + if (this.destroyed) return; + this.status = status; + this.configETag = result.response.headers.get('ETag') ?? ''; + if (!this.configETag) throw new Error('People sweep settings response omitted its config ETag. Reload before editing.'); + } + + private async run(action: () => Promise): Promise { + if (this.busy) return; + this.busy = true; + this.error = ''; + try { await action(); } + catch (cause) { + if (!this.destroyed) this.error = cause instanceof Error ? cause.message : 'Unable to update people sweep settings.'; + } finally { + if (!this.destroyed) this.busy = false; + } + } +} + +export function credentialStatus(profile: PeopleInferenceProfileSetting): string { + if (profile.protocol === 'codex_app_server') return 'Codex is unavailable in this release'; + if (profile.credential_source === 'env') { + const variable = profile.credential_env || 'the provider variable'; + return profile.credential_configured ? `Environment ${variable} ready` : `Set ${variable} on daemon host`; + } + return profile.credential_configured ? 'Stored key' : 'Key needed'; +} diff --git a/web/tests/people-inference-viewport.spec.ts b/web/tests/people-inference-viewport.spec.ts new file mode 100644 index 000000000..d01651fe4 --- /dev/null +++ b/web/tests/people-inference-viewport.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; +import { installMixedArchive } from './e2e/fixtures/mixed-archive'; + +for (const width of [390, 320]) { + test(`People sweep setup fits a ${width}px viewport with disclosure open`, async ({ page }) => { + await installMixedArchive(page); + const name = 'routed-profile-with-a-long-visible-name'; + const profile = { + name, preset_id: 'openrouter', protocol: 'openai-chat', model: 'model-one', + endpoint: 'https://openrouter.example.test/a/long/path/to/the/selected/endpoint/for/this/model', + credential_source: 'stored', credential_configured: true, checked: true, + consent_active: false, fingerprint: 'synthetic-fingerprint', + allowed_sources: ['conversation_text', 'meeting_text'], source_since: '2025-01-01', + allow_sensitive: false, retention_posture: 'No retention', training_posture: 'No training', + }; + await page.route('**/api/v1/settings/people-inference', (route) => route.fulfill({ + headers: { ETag: '"config-a"' }, json: { + stored_credentials_supported: true, + profiles: [profile, { ...profile, name: 'spare-profile', fingerprint: 'spare-fingerprint' }], + configured_name: name, running_name: 'previous-profile', + configured_enabled: true, running_enabled: true, pending_restart: true, + }, + })); + await page.route('**/api/v1/settings/people-inference/providers/*/check', (route) => route.fulfill({ + json: { ok: true, fingerprint: 'synthetic-fingerprint' }, + })); + await page.setViewportSize({ width, height: 844 }); + await page.goto(`/?explore=${encodeURIComponent(JSON.stringify({ workspace: 'settings' }))}`); + await page.getByRole('button', { name: /^People sweep/ }).click(); + await expect(page.getByRole('heading', { name: 'People sweep' })).toBeVisible(); + await page.getByRole('button', { name: 'Check provider' }).click(); + await expect(page.getByRole('region', { name: 'Archive disclosure' })).toBeVisible(); + + const overflow = await page.evaluate(() => { + const component = document.querySelector('[aria-label="People sweep settings"]')!; + return { viewport: window.innerWidth, document: document.documentElement.scrollWidth, + component: component.scrollWidth, componentWidth: component.clientWidth }; + }); + expect(overflow.document).toBeLessThanOrEqual(overflow.viewport); + expect(overflow.component).toBeLessThanOrEqual(overflow.componentWidth); + await expect(page.getByRole('button', { name: 'Select and enable' })).toBeDisabled(); + await page.getByRole('combobox', { name: /^Profile:/ }).click(); + await page.getByRole('option', { name: 'spare-profile' }).click(); + await page.getByRole('button', { name: 'Remove profile' }).click(); + await expect(page.getByRole('button', { name: 'Confirm removal' })).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + }); +}