From b003394568999afe8b29adf86d44ddec616c5a59 Mon Sep 17 00:00:00 2001 From: toki Date: Mon, 3 Aug 2026 10:21:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(agent-ops):=20=ED=99=98=EA=B2=BD=EB=B3=84?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=20=EB=B0=9C=EA=B8=89=20=EC=8A=A4=ED=82=AC?= =?UTF-8?q?=EC=9D=84=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=EB=A1=9C=20?= =?UTF-8?q?=EC=8A=B9=EA=B2=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev와 dev-corp 요청을 명시적 환경 프로필로 라우팅하고, private override 없이 단일 실행기에서 Edge 검증과 환경별 metadata 정책을 처리한다. --- agent-ops/rules/project/rules.md | 2 +- .../project/openai-usage-token-issue/SKILL.md | 236 ++- .../profiles/dev-corp.json | 12 + .../profiles/dev.json | 11 + .../scripts/issue_token.py | 1363 +++++++++++++++++ .../scripts/remote_edge_transaction.rb | 618 ++++++++ 6 files changed, 2098 insertions(+), 144 deletions(-) create mode 100644 agent-ops/skills/project/openai-usage-token-issue/profiles/dev-corp.json create mode 100644 agent-ops/skills/project/openai-usage-token-issue/profiles/dev.json create mode 100644 agent-ops/skills/project/openai-usage-token-issue/scripts/issue_token.py create mode 100644 agent-ops/skills/project/openai-usage-token-issue/scripts/remote_edge_transaction.rb diff --git a/agent-ops/rules/project/rules.md b/agent-ops/rules/project/rules.md index 8ee079e4..f2237766 100644 --- a/agent-ops/rules/project/rules.md +++ b/agent-ops/rules/project/rules.md @@ -104,7 +104,7 @@ - `설정으로 빼`는 현재 문맥이 UI 노출 제외이고 대상 설정이 식별될 때만 위 라우팅을 사용한다. config 추출·스키마 삭제와 구분되지 않으면 자동 기록하지 않고 확인 대상으로 남긴다. - UI 없는 사용자 CRUD, OpenAI-compatible 사용자/principal 추가·조회·수정·비활성화·삭제, principal token 운영 CRUD: `agent-ops/skills/project/iop-user-crud-ops/SKILL.md` - Confluence 문서 작성, 컨플 문서 생성·갱신·검토, Lab2 문서 작성, lgucorp 위키 업데이트: `agent-ops/skills/project/lgucorp-confluence-docs/SKILL.md` -- OpenAI-compatible 사용자 token 발급·추가, dev-corp 사용자 추가와 token 발급, principal_ref token 등록, principal alias 매핑, raw IOP token 1회 전달: `agent-ops/skills/project/openai-usage-token-issue/SKILL.md` +- dev 또는 dev-corp OpenAI-compatible 사용자 token 발급·추가, `dev에 <사용자> 사용자 토큰 만들어줘`, `dev-corp에 <사용자> 사용자 토큰 만들어줘`, principal_ref token 등록, principal alias 매핑, raw IOP token 1회 전달: `agent-ops/skills/project/openai-usage-token-issue/SKILL.md` - dev-corp 배포, dev-corp runtime 배포, 회사망 mac-mini Edge/Node dev-corp 환경 배포, dev-corp provider pool 배포, dev-corp OpenAI-compatible capacity smoke 검증: `agent-ops/skills/project/dev-corp-runtime-deploy/SKILL.md` - dev 배포, dev-runtime 배포, Edge/Node dev 환경 배포, provider pool 배포, OpenAI-compatible capacity smoke 검증: `agent-ops/skills/project/dev-runtime-deploy/SKILL.md` - 사용자 실행 파이프라인 검증, repo 내부 edge-node 진단, 메시지 2회 왕복, edge command 응답, 보조 E2E smoke, full-cycle 실제 구동, `scripts/dev/edge.sh`/`scripts/dev/node.sh` 진단 테스트: `agent-ops/skills/project/e2e-smoke/SKILL.md` diff --git a/agent-ops/skills/project/openai-usage-token-issue/SKILL.md b/agent-ops/skills/project/openai-usage-token-issue/SKILL.md index 11f3001b..24d92095 100644 --- a/agent-ops/skills/project/openai-usage-token-issue/SKILL.md +++ b/agent-ops/skills/project/openai-usage-token-issue/SKILL.md @@ -1,173 +1,123 @@ --- name: openai-usage-token-issue -version: 1.0.6 -description: OpenAI-compatible 사용자 추가와 usage metering용 IOP token을 발급하고 private Edge mapping, local secret store, 사용자가 승인한 Confluence metadata table을 동기화하는 운영 절차 +description: dev 또는 dev-corp OpenAI-compatible 사용자 토큰 생성 요청을 환경별 단일 실행으로 처리한다. 사용자가 "dev에 사용자 토큰 만들어줘", "dev-corp 사용자 추가하고 토큰 발급해줘", principal_ref 등록, raw IOP token 1회 전달을 요청할 때 사용한다. --- # openai-usage-token-issue ## 목적 -OpenAI-compatible 사용량 metering에 쓸 IOP bearer token을 발급하고, raw token 없이 `token_ref`, token hash, `principal_ref`, 내부 alias 매핑만 운영 기록에 남긴다. -IOP는 사용자/테넌트 source of truth를 소유하지 않고, 외부 principal id 또는 내부 운영 id를 참조값으로만 다룬다. -이 스킬은 dev-corp에서 사용자 추가와 token 발급을 함께 요청받았을 때의 단일 진입점이다. +자연어 사용자 토큰 생성 요청 한 건을 선택한 환경에서 단일 실행기로 처리한다. 안전 절차를 수동 명령으로 펼치지 않는다. -## 언제 호출할지 +단일 실행기는 다음을 순서대로 묶는다. -- OpenAI-compatible 호출 사용량을 특정 `principal_ref` 또는 내부 alias로 귀속할 IOP token을 새로 발급할 때 -- dev/dev-corp 운영자가 Grafana 사용량 label에 노출될 `token_ref`, `principal_alias`를 준비할 때 -- raw bearer token을 tracked 파일이나 최종 보고에 남기지 않고 1회 전달해야 할 때 -- dev-corp 사용자 추가와 함께 해당 사용자의 IOP token 발급·private Edge mapping 반영을 요청받았을 때 +1. 환경 프로필, local secret store, active Edge 사전점검 +2. `principal_ref`와 `token_ref` 중복 확인 및 idempotent 재개 +3. raw token 생성과 source-format-preserving Edge 후보 생성 +4. config check, restart-required 판정, backup/cutover/restart/자동 rollback +5. 환경별 OpenAI-compatible API smoke +6. dev-corp Confluence metadata 동기화 +7. tracked leak 확인과 macOS clipboard 1회 전달 + +## 환경 선택 + +사용자 요청에서 환경을 명시적으로 선택한다. 환경이 없으면 추론하거나 기본값을 적용하지 않고 `environment_not_supported`로 중단한다. + +| 자연어 요청 | `env` | API 검증 | Confluence | +|---|---|---|---| +| `dev에 <사용자> 사용자 토큰 만들어줘` | `dev` | SSH runner 내부 `127.0.0.1` | `not-applicable` | +| `dev-corp에 <사용자> 사용자 토큰 만들어줘` | `dev-corp` | 승인된 public HTTPS | 필수 metadata 동기화 | + +`dev` public endpoint는 HTTP이므로 raw token을 public URL로 보내지 않는다. 실행기는 SSH stdin으로 runner에 전달하고 loopback API에서만 smoke한다. ## 입력 -- `operation`: 사용자 추가와 함께 발급할 때는 `create`를 사용한다. token만 추가하거나 회전할 때는 작업 의도를 명시한다. (기본: `create`) -- `env`: 대상 환경. 사용자 추가 발급 기본값은 `dev-corp`이다. (기본: `dev-corp`) -- `principal_ref`: 외부 사용자/테넌트 프로젝트 또는 운영 시스템의 principal 참조값 (필수) -- `principal_alias`: Grafana에 노출할 내부 alias. 없으면 `principal_ref`에서 secret이 아닌 짧은 별칭을 정한다. (선택) -- `token_ref`: metric label과 설정에 쓸 안정 token 참조값. 한 `principal_ref`가 여러 앱/통합을 운영하면 앱/통합/용도별로 서로 다른 `token_ref`를 발급한다. 없으면 token hash prefix로 만든다. (선택) -- `output_path`: raw token을 보관해야 한다면 이 프로젝트 repo root의 gitignored `token/.dev-corp-iop-token`을 사용한다. 이 파일 외의 운영 기록은 raw token 없이 남긴다. (선택) +실제 식별자는 command argument, 환경변수, tracked 파일에 넣지 않고 실행기의 stdin JSON으로만 전달한다. -## 이 프로젝트의 local secret store +```json +{ + "operation": "create", + "env": "dev", + "principal_ref": "", + "principal_alias": "", + "token_ref": "" +} +``` -- `token/.dev-corp-iop-token`은 repo root의 operator-local 파일이며, 한 줄에 `: `을 기록한다. 기본 `private-user-key`는 요청자가 지정한 식별자다. -- 발급 전에 `.gitignore`에 정확한 `/token/` 항목이 있는지 확인하고, 없으면 token directory 경로만 추가한다. raw token이나 사용자 값 자체를 `.gitignore`에 쓰지 않는다. -- 파일은 `umask 077`으로 만들거나 유지해 mode `0600`이어야 한다. 기존 기록을 보존하기 위해 `token/` 안의 후보 파일을 만든 뒤 검증하고 원자적으로 교체한다. -- 요청자가 이메일을 private-user-key 또는 dev-corp `principal_ref`로 명시하면, 그 이메일은 이 ignored local store와 private Edge 설정에만 둔다. tracked 파일, 스킬 예시, 최종 보고에는 실제 이메일을 쓰지 않는다. +- `operation`: `create`만 지원한다. +- `env`: `dev` 또는 `dev-corp`를 반드시 전달한다. +- `principal_ref`: 요청자가 지정한 외부 principal 참조값이다. +- `principal_alias`: 없으면 principal local-part를 ASCII kebab-case로 정규화한다. +- `token_ref`: 없으면 `iop--`를 사용한다. -## 승인된 Confluence metadata table 동기화 +## 단일 실행 -- 사용자가 사용자별 발급 현황을 특정 Confluence path에 표로 동기화하라고 명시했을 때만 target URL을 ignored operator-local 파일 `token/.dev-corp-iop-confluence-target`에 한 줄로 보관한다. 이 파일도 mode `0600`, ignored, untracked여야 하며, tracked 스킬·문서·최종 보고에는 draft share URL 또는 식별자를 쓰지 않는다. -- Confluence 갱신 전에는 local secret store의 private-user-key 집합과 active `openai.principal_tokens[]`의 `principal_ref` 집합을 1:1로 대조한다. local store에 없는 service/smoke principal은 표에서 제외하며, 불일치 또는 중복이면 표를 쓰지 않고 불일치를 보고한다. -- 표의 source of truth는 local secret store의 private-user-key와 active Edge의 `principal_ref`, `principal_alias`, `token_ref`, 활성 상태다. 표는 `사용자`, `principal alias`, `token ref`, `상태`, `동기화 시각` 열만 사용하고 행은 `principal_ref`로 upsert한다. raw token, token hash, Authorization 값, provider credential은 storage body와 API payload에 넣지 않는다. -- 실제 갱신은 `lgucorp-confluence-docs` 스킬의 `sync-token-metadata-table` 절차를 따른다. HTTP Basic 인증과 table JSON payload는 stdin 또는 동등한 비노출 stream으로 전달하며 raw token을 shell 인자 또는 payload에 넣지 않는다. -- target draft/page는 id, title, status, version, parent, storage body를 먼저 읽는다. body가 비어 있으면 관리 섹션과 표를 쓰고, 비어 있지 않은 body는 `IOP 사용자 토큰 발급 현황` 관리 섹션만 교체한다. -- Confluence write 뒤에는 같은 content id를 다시 읽어 title·status·version·parent와 metadata 행 수·사용자/alias/ref/status 값을 active mapping과 내부 비교한다. 인증·권한·version conflict·재조회 실패 시 raw token store나 Edge mapping을 변경하거나 write를 자동 재시도하지 않는다. - -## 사용자 추가(create) 기준 - -- 요청자가 이메일을 명시하면 그 값은 private-user-key와 dev-corp `principal_ref`로만 사용한다. tracked 문서·스킬 예시·최종 보고에는 실제 값을 쓰지 않는다. -- `principal_alias`는 요청자 식별에 쓸 짧은 ASCII alias로 정하고, `token_ref`는 `iop-dev-corp-`처럼 환경과 용도를 포함한 안정값으로 정한다. -- active private Edge config에서 같은 `principal_ref` 또는 `token_ref`를 먼저 찾는다. 하나라도 있으면 새 raw token을 발급하지 않고 기존 매핑의 활성 상태를 확인한다. -- `operation=create`은 `iop-user-crud-ops`의 create 책임과 함께 적용하되, raw token 생성·local secret store·principal token 매핑 절차는 이 스킬이 소유한다. - -## 먼저 확인할 것 - -- [ ] `principal_ref`가 secret, provider token, provider identity가 아니라 외부 시스템 참조값인지 확인한다. 운영자가 private dev-corp 매핑의 이메일을 명시한 경우에는 ignored local store와 private Edge 설정으로 범위를 제한한다. -- [ ] raw token을 tracked `docs/`, `agent-roadmap/`, `agent-spec/`, `configs/`, git diff, shell history, 최종 보고에 남기지 않을 전달 경로를 정한다. -- [ ] raw token 보관이 필요한 경우 `token/.dev-corp-iop-token`의 ignore 상태와 mode `0600`을 확인한다. -- [ ] Confluence 동기화를 요청받은 경우 `token/.dev-corp-iop-confluence-target`의 mode `0600`, ignored, untracked 상태와 대상 page 접근을 확인한다. -- [ ] `token_ref`와 `principal_alias`가 낮은 cardinality label로 안전한 값인지 확인한다. -- [ ] 같은 `principal_ref`에 여러 앱/통합용 token이 필요한 경우 각 token의 앱/통합/용도 구분이 `token_ref`에 반영되는지 확인한다. -- [ ] 기존 token을 회전하는 경우 기존 `token_ref`를 재사용할지 새 `token_ref`를 만들지 운영 정책을 확인한다. - -## 실행 절차 - -1. **입력 정규화** - - `principal_ref` 앞뒤 공백을 제거한다. - - `principal_alias`는 공백을 `-`로 바꾸고, 운영자가 식별할 수 있는 짧은 ASCII alias로 둔다. - - `token_ref`를 직접 받지 않았으면 생성할 token hash의 앞 16자를 사용해 `ioptok_` 형식으로 만든다. - - `operation=create`이면 raw token 생성 전에 active private Edge config의 `principal_ref`와 `token_ref` 중복을 확인한다. 기존 매핑이 있으면 발급을 중단하고 활성 상태를 검증한다. - -2. **raw token 생성** - - 현재 shell에서 `set +x`를 확인한다. - - 아래 형태의 고엔트로피 token을 생성한다. 실제 출력은 operator에게 1회만 전달한다. +repo root에서 아래 고정 command를 시작하고 request JSON은 process stdin으로 보낸다. 실제 식별자를 shell command 문자열에 보간하지 않는다. ```bash -set +x -umask 077 -raw_token="iop_$(openssl rand -base64 36 | tr '+/' '-_' | tr -d '=')" -token_hash="$(printf '%s' "$raw_token" | sha256sum | awk '{print $1}')" -token_ref="${token_ref:-ioptok_${token_hash:0:16}}" +python3 agent-ops/skills/project/openai-usage-token-issue/scripts/issue_token.py ``` -3. **local secret store 갱신** - - raw token을 보관해야 할 때만 repo root의 `token/.dev-corp-iop-token`에 `: ` 한 줄을 추가한다. - - 새 파일 또는 후보 파일은 `umask 077`으로 만들고, 기존 매핑을 보존한 뒤 mode `0600` 및 ignore 상태를 검증한다. - - raw token을 shell 인자, trace, 화면 출력, tracked diff에 노출하지 않는다. +Codex는 자연어 요청을 stdin JSON으로 변환해 실행기에 한 번 전달한다. redacted JSON 결과만 해석하며 내부 단계를 수동 명령으로 재구성하지 않는다. -4. **사용자 추가와 private Edge 후보 반영** - - `operation=create`이면 active private Edge config의 `openai.principal_tokens[]`에서 `principal_ref`와 `token_ref` 중복을 다시 확인한다. - - local secret store는 `token/` 안의 후보 파일에 기존 레코드와 새 `: `을 함께 기록하고, mode `0600`·ignore 상태를 확인한 뒤 원자적으로 교체한다. - - raw token은 SSH 표준 입력처럼 비노출 stream으로만 private Edge config 후보 생성기에 전달한다. 후보 config에는 `token_ref`, SHA-256 hash, `principal_ref`, `principal_alias`만 기록한다. - - `openai.principal_tokens[]` 변경은 restart-required다. 환경 배포 스킬의 config check·refresh dry-run·backup/mv cutover·Edge restart 절차를 따른다. live apply 완료로 처리하지 않는다. - -5. **raw-token-free 운영 기록 작성** - - tracked 문서나 공유 운영 기록에는 아래 필드만 남긴다. - -```yaml -token_ref: "" -principal_ref: "" -principal_alias: "" -token_hash_sha256: "" -status: active -``` - -6. **승인된 Confluence metadata table 동기화** - - 동기화 요청이 있을 때만 local secret store의 private-user-key와 active Edge `principal_tokens[]`를 `principal_ref` 기준으로 1:1 대조한다. 불일치 또는 중복이면 표를 쓰지 않고 `blocked`로 보고한다. - - target URL은 `token/.dev-corp-iop-confluence-target`에서만 읽고, `lgucorp-confluence-docs`의 `sync-token-metadata-table` 인증·대상 재조회·version 기반 update 절차를 사용한다. - - 표에는 `사용자`, `principal alias`, `token ref`, `상태: active`, `동기화 시각`만 넣는다. raw token, token hash, Authorization 값, provider credential, draft share URL은 넣지 않는다. - - target body가 비어 있으면 관리 섹션과 표를 쓰고, 비어 있지 않으면 `IOP 사용자 토큰 발급 현황` 관리 섹션만 대체한다. 다른 섹션, title, parent, 권한은 변경하지 않는다. - - 갱신 후 page 재조회로 table의 행 수·사용자/alias/ref/status metadata를 active mapping과 내부 검증한다. 실패하면 결과를 `blocked`로 보고하고, 앞선 token/Edge 상태를 되돌리거나 새 token을 발급하지 않는다. - -7. **raw token 1회 전달** - - raw token은 operator-only 채널로 한 번만 전달한다. - - 채팅 최종 보고, git diff, tracked 문서, 검증 출력에는 raw token을 쓰지 않는다. - -8. **누출 확인** - - 저장소 안에 raw token이 남지 않았는지 조용한 검색으로 확인한다. 실패 시 출력에 raw token이 찍히지 않게 한다. - - 검사 대상에서 의도된 ignored `token/` directory는 제외하되, 그 안의 secret store가 추적되지 않았음을 별도로 확인한다. +읽기 전용 환경 준비 상태 확인도 같은 stdin request를 사용한다. ```bash -if rg -q -F "$raw_token" agent-ops agent-roadmap agent-spec agent-contract docs configs apps packages proto; then - echo "raw token leak detected in tracked workspace paths" - exit 1 -fi -echo "raw token not found in tracked workspace paths" +python3 agent-ops/skills/project/openai-usage-token-issue/scripts/issue_token.py --preflight ``` -9. **결과 보고** - - `token_ref`, `principal_ref`, `principal_alias`, 매핑 기록 위치, Confluence metadata table 동기화 상태, raw token 전달 여부만 보고한다. - - raw token과 전체 token hash는 보고하지 않는다. +로컬 결정론적 테스트: -## 실행 결과 검증 - -- [ ] `operation=create`의 active private Edge config에 대상 `principal_ref`와 `token_ref`가 각각 한 번만 존재하는가 -- [ ] raw token이 operator에게 1회만 전달되었는가 -- [ ] tracked 파일에는 raw token이 없고, `token_ref`, `principal_ref`, `principal_alias`, hash만 남았는가 -- [ ] `token/.dev-corp-iop-token`이 mode `0600`이고 `git check-ignore -q token/.dev-corp-iop-token`을 통과하며 `git ls-files --error-unmatch token/.dev-corp-iop-token`이 실패하는가 -- [ ] Confluence 동기화를 요청한 경우 `token/.dev-corp-iop-confluence-target`이 mode `0600`, ignored, untracked이고, local private-user-key 집합과 active Edge `principal_ref` 집합이 일치하는가 -- [ ] Confluence table은 local secret store의 각 사용자당 정확히 한 행이고 `사용자`, alias, ref, status metadata가 active mapping과 일치하며 raw token·token hash를 포함하지 않는가 -- [ ] Confluence 갱신 뒤 같은 draft/page의 title, status, parent가 유지되고 version·table 행 수가 기대값으로 재조회되는가 -- [ ] Edge restart 후 새 token으로 `/v1/models`와 `/v1/chat/completions` 인증이 성공하고, 가능한 경우 usage metric의 principal/token label이 관측되는가 -- [ ] `rg -q -F "$raw_token" ...` 누출 확인이 실패하지 않았는가 -- [ ] 최종 보고에 raw token, provider token, provider identity, raw prompt/response가 포함되지 않았는가 -- 검증 실패 시: raw token을 폐기하고 새 token을 발급한다. 누출된 tracked 파일은 수정한 뒤 다시 누출 확인을 실행하고, local secret store는 새 후보 파일 검증 뒤에만 교체한다. - -## 출력 형식 - -```text -OpenAI usage token issue -- operation: -- token_ref: -- principal_ref: -- principal_alias: -- secret_store: token/.dev-corp-iop-token -- edge_mapping: -- confluence_metadata_sync: -- mapping_record: -- raw_token_delivered_once: -- leak_check: -- notes: raw token omitted from report +```bash +python3 agent-ops/skills/project/openai-usage-token-issue/scripts/issue_token.py --self-test ``` -## 금지 사항 +## 환경 프로필과 private 경계 -- raw token을 tracked 파일, 최종 보고, 로그, metric label, Grafana dashboard, shell trace에 남기지 않는다. -- raw token 또는 실제 요청자 식별자를 `.gitignore`, 스킬 예시, tracked 문서, shell 인자에 쓰지 않는다. `.gitignore`에는 `/token/` directory 경로만 둔다. -- raw token, token hash, Authorization 값, provider credential, draft share URL을 Confluence metadata table 또는 Confluence API payload에 넣지 않는다. -- local secret store와 active Edge mapping이 1:1로 대조되지 않은 상태에서 Confluence 표를 갱신하거나, 관리 섹션 밖의 본문·title·parent를 변경하지 않는다. -- active config에 같은 `principal_ref` 또는 `token_ref`가 있는데 새 raw token을 발급하거나 중복 entry를 추가하지 않는다. -- `metadata.user`, provider token, provider identity를 사용자 식별 source로 쓰지 않는다. -- `request_id`, `session_id`, raw token, raw prompt, raw response 같은 high-cardinality 또는 secret 값을 metric label 후보로 만들지 않는다. -- 사용자 CRUD, tenant/org source of truth, token 제한 enforcement를 이 스킬 책임으로 확장하지 않는다. +- non-secret 환경 설정은 `profiles/dev.json`, `profiles/dev-corp.json`만 사용한다. request가 host, path, URL, transport를 덮어쓰게 하지 않는다. +- remote helper도 `environment` allowlist로 exact runtime path, listener, process command를 선택한다. +- raw token store는 `dev=token/.dev-iop-token`, `dev-corp=token/.dev-corp-iop-token`이다. +- dev-corp Confluence credential은 `token/.lgu-atlassian-user`, `token/.lgu-atlassian-token`에서만 읽는다. +- 위 operator-local 파일은 모두 `0600`, ignored, untracked여야 한다. tracked 스킬과 프로필에 개인 credential을 기록하지 않는다. + +## 완료 기준 + +공통 완료 필드: + +- `result=completed` +- `edge_mapping=activated|existing-verified` +- `api_smoke=passed` +- `raw_token_delivered_once=true` +- `delivery_channel=local_clipboard` +- `leak_check=passed` + +환경별 metadata 필드: + +- `dev`: `confluence_metadata_sync=not-applicable` +- `dev-corp`: `confluence_metadata_sync=updated` + +`result=blocked`이면 완료로 보고하지 않는다. 동일 입력 재실행은 중복 발급하지 않고 active mapping과 local raw token hash를 대조해 재개한다. + +## 안전 규칙 + +- raw token은 선택 환경의 local secret store에만 저장하고 최종 응답, 로그, tracked diff에 쓰지 않는다. +- raw token은 remote process argument에 넣지 않고 SSH stdin payload로만 전달한다. +- Edge config에는 `token_ref`, SHA-256 hash, `principal_ref`, `principal_alias`만 기록한다. +- `openai.principal_tokens[]` 변경은 restart-required로 처리한다. candidate check, cutover, exact listener identity 확인, restart, rollback을 생략하지 않는다. +- dev-corp Confluence 표에는 사용자, alias, token ref, 상태, 동기화 시각만 기록한다. raw token, token hash, Authorization, provider credential을 넣지 않는다. +- Confluence write는 최신 version에 한 번만 수행하고 409를 포함한 실패를 자동 재시도하지 않는다. +- Confluence 실패는 활성화된 Edge/store를 되돌리지 않고 clipboard 전달을 막아 동일 command로 재개한다. +- active config에 같은 `principal_ref` 또는 `token_ref`가 있으면 새 raw token을 발급하지 않는다. + +## 수동 확인이 필요한 safe code + +다음 code는 자동 반복하지 않고 운영 상태를 먼저 확인한다. + +- `operation_not_supported_by_single_entry` +- `environment_not_supported`, `environment_profile_invalid` +- `existing_mapping_ambiguous`, `store_edge_mapping_mismatch` +- `confluence_create_would_remove_user`, `confluence_target_ambiguous` +- `confluence_managed_heading_invalid` +- `edge_listener_unexpected`, `edge_automatic_rollback_failed` + +그 외 network/readiness 실패는 동일 입력으로 한 번 재실행할 수 있다. raw token 또는 실제 principal을 최종 보고에 포함하지 않는다. diff --git a/agent-ops/skills/project/openai-usage-token-issue/profiles/dev-corp.json b/agent-ops/skills/project/openai-usage-token-issue/profiles/dev-corp.json new file mode 100644 index 00000000..16e3e4fc --- /dev/null +++ b/agent-ops/skills/project/openai-usage-token-issue/profiles/dev-corp.json @@ -0,0 +1,12 @@ +{ + "environment": "dev-corp", + "edge_ssh": "toki@iop.ai.kr", + "secret_store": "token/.dev-corp-iop-token", + "openai_smoke_transport": "public-https", + "openai_base_url": "https://digitalplatform.iop.ai.kr/v1", + "smoke_model": "ornith:35b", + "metrics_transport": "public-http", + "metrics_url": "http://iop.ai.kr:19105/metrics", + "confluence_enabled": true, + "confluence_user_store": "token/.lgu-atlassian-user" +} diff --git a/agent-ops/skills/project/openai-usage-token-issue/profiles/dev.json b/agent-ops/skills/project/openai-usage-token-issue/profiles/dev.json new file mode 100644 index 00000000..35f856fb --- /dev/null +++ b/agent-ops/skills/project/openai-usage-token-issue/profiles/dev.json @@ -0,0 +1,11 @@ +{ + "environment": "dev", + "edge_ssh": "toki@toki-labs.com", + "secret_store": "token/.dev-iop-token", + "openai_smoke_transport": "ssh-loopback", + "openai_base_url": "http://127.0.0.1:18083/v1", + "smoke_model": "laguna-s:2.1", + "metrics_transport": "ssh-loopback", + "metrics_url": "http://127.0.0.1:19101/metrics", + "confluence_enabled": false +} diff --git a/agent-ops/skills/project/openai-usage-token-issue/scripts/issue_token.py b/agent-ops/skills/project/openai-usage-token-issue/scripts/issue_token.py new file mode 100644 index 00000000..0fc89e77 --- /dev/null +++ b/agent-ops/skills/project/openai-usage-token-issue/scripts/issue_token.py @@ -0,0 +1,1363 @@ +"""Single-entry dev and dev-corp IOP token issuance workflow. + +Sensitive request data is accepted only as JSON on stdin. The command emits a +redacted result and copies the raw token to the local macOS clipboard only after +Edge activation, environment-specific API smoke, metadata synchronization, and +leak checks pass. +""" + +from __future__ import annotations + +import argparse +import base64 +import fcntl +import hashlib +import html +import json +import os +import re +import secrets +import shlex +import stat +import subprocess +import sys +import time +import unicodedata +import urllib.error +import urllib.parse +import urllib.request +from contextlib import contextmanager +from html.parser import HTMLParser +from pathlib import Path +from typing import Any, NoReturn, cast + +ATLASSIAN_BASE_URL = "https://lgucorp.atlassian.net" +CONFLUENCE_FOLDER_ID = "650886407" +CONFLUENCE_TITLE = "IOP 계정 발급 현황" +MANAGED_HEADING = "IOP 사용자 토큰 발급 현황" +TABLE_HEADERS = ("사용자", "principal alias", "token ref", "상태", "동기화 시각") +SUPPORTED_ENVIRONMENTS = ("dev", "dev-corp") + + +class WorkflowFailure(RuntimeError): + def __init__(self, code: str): + self.code = code + super().__init__(code) + + +class ConfluenceHTTPFailure(WorkflowFailure): + def __init__(self, status_code: int): + self.status_code = status_code + super().__init__(f"confluence_http_{status_code}") + + +class CurlTransportFailure(WorkflowFailure): + def __init__(self): + super().__init__("curl_transport_failed") + + +def fail(code: str) -> NoReturn: + raise WorkflowFailure(code) + + +def repo_root() -> Path: + root = Path(__file__).resolve().parents[5] + if not (root / ".git").exists(): + fail("repo_root_invalid") + return root + + +def load_profile(root: Path, environment: str) -> dict[str, Any]: + if environment not in SUPPORTED_ENVIRONMENTS: + fail("environment_not_supported") + path = ( + root + / "agent-ops/skills/project/openai-usage-token-issue/profiles" + / f"{environment}.json" + ) + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + fail("environment_profile_invalid") + if not isinstance(value, dict) or value.get("environment") != environment: + fail("environment_profile_invalid") + required_strings = ( + "edge_ssh", + "secret_store", + "openai_smoke_transport", + "openai_base_url", + "smoke_model", + "metrics_transport", + "metrics_url", + ) + if any(not isinstance(value.get(key), str) or not value[key] for key in required_strings): + fail("environment_profile_invalid") + if not isinstance(value.get("confluence_enabled"), bool): + fail("environment_profile_invalid") + expected_stores = { + "dev": "token/.dev-iop-token", + "dev-corp": "token/.dev-corp-iop-token", + } + expected_ssh = {"dev": "toki@toki-labs.com", "dev-corp": "toki@iop.ai.kr"} + if value["secret_store"] != expected_stores[environment]: + fail("environment_profile_invalid") + if value["edge_ssh"] != expected_ssh[environment]: + fail("environment_profile_invalid") + parsed_api = urllib.parse.urlsplit(str(value["openai_base_url"])) + transport = value["openai_smoke_transport"] + if transport == "public-https": + if parsed_api.scheme != "https" or not parsed_api.netloc: + fail("environment_profile_invalid") + elif transport == "ssh-loopback": + if parsed_api.scheme != "http" or parsed_api.hostname not in {"127.0.0.1", "::1", "localhost"}: + fail("environment_profile_invalid") + else: + fail("environment_profile_invalid") + if value["metrics_transport"] not in {"public-http", "ssh-loopback"}: + fail("environment_profile_invalid") + if value["confluence_enabled"] and not isinstance( + value.get("confluence_user_store"), str + ): + fail("environment_profile_invalid") + return cast(dict[str, Any], value) + + +def run_quiet( + argv: list[str], *, cwd: Path, input_bytes: bytes | None = None, timeout: int = 30 +) -> subprocess.CompletedProcess[bytes]: + try: + return subprocess.run( + argv, + cwd=cwd, + input=input_bytes, + capture_output=True, + timeout=timeout, + check=False, + ) + except subprocess.TimeoutExpired: + fail("command_timeout") + + +def git_is_ignored(root: Path, path: Path) -> bool: + relative = path.relative_to(root) + result = run_quiet(["git", "check-ignore", "-q", "--", str(relative)], cwd=root) + return result.returncode == 0 + + +def git_is_tracked(root: Path, path: Path) -> bool: + relative = path.relative_to(root) + result = run_quiet( + ["git", "ls-files", "--error-unmatch", "--", str(relative)], cwd=root + ) + return result.returncode == 0 + + +def require_secure_secret(root: Path, path: Path, *, must_exist: bool = True) -> None: + if path.is_symlink() or path.parent.is_symlink(): + fail("secret_store_symlink_invalid") + try: + path.parent.resolve().relative_to(root.resolve()) + except ValueError: + fail("secret_store_path_invalid") + if must_exist and not path.is_file(): + fail("secret_store_missing") + if path.exists(): + mode = stat.S_IMODE(path.stat().st_mode) + if mode != 0o600: + fail("secret_store_mode_invalid") + if not git_is_ignored(root, path) or git_is_tracked(root, path): + fail("secret_store_tracking_invalid") + + +@contextmanager +def local_transaction_lock(root: Path, *, shared: bool): + token_dir = root / "token" + if ( + not token_dir.is_dir() + or token_dir.is_symlink() + or not git_is_ignored(root, token_dir) + ): + fail("local_transaction_lock_invalid") + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(token_dir, flags) + except OSError: + fail("local_transaction_lock_invalid") + try: + operation = fcntl.LOCK_SH if shared else fcntl.LOCK_EX + try: + fcntl.flock(descriptor, operation | fcntl.LOCK_NB) + except BlockingIOError: + fail("local_transaction_busy") + yield + finally: + try: + fcntl.flock(descriptor, fcntl.LOCK_UN) + finally: + os.close(descriptor) + + +def normalize_alias(principal_ref: str, requested: str | None) -> str: + source = requested.strip() if requested else principal_ref.split("@", 1)[0] + ascii_value = ( + unicodedata.normalize("NFKD", source).encode("ascii", "ignore").decode("ascii") + ) + alias = re.sub(r"[^a-z0-9]+", "-", ascii_value.lower()).strip("-") + alias = re.sub(r"-+", "-", alias)[:40].rstrip("-") + if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,39}", alias): + fail("principal_alias_invalid") + return alias + + +def parse_request(stdin_text: str) -> dict[str, str]: + try: + payload = json.loads(stdin_text) + except json.JSONDecodeError: + fail("request_json_invalid") + if not isinstance(payload, dict): + fail("request_json_invalid") + if payload.get("operation", "create") != "create": + fail("operation_not_supported_by_single_entry") + environment = payload.get("env") + if not isinstance(environment, str) or environment not in SUPPORTED_ENVIRONMENTS: + fail("environment_not_supported") + principal_value = payload.get("principal_ref") + alias_value = payload.get("principal_alias") + token_ref_value = payload.get("token_ref") + if not isinstance(principal_value, str): + fail("principal_ref_invalid") + if alias_value is not None and not isinstance(alias_value, str): + fail("principal_alias_invalid") + if token_ref_value is not None and not isinstance(token_ref_value, str): + fail("token_ref_invalid") + principal_ref = principal_value.strip() + if ( + not principal_ref + or len(principal_ref) > 254 + or any(char in principal_ref for char in "\r\n:\0") + or any(char.isspace() for char in principal_ref) + ): + fail("principal_ref_invalid") + alias = normalize_alias(principal_ref, alias_value) + token_ref = (token_ref_value or f"iop-{environment}-{alias}").strip() + if not re.fullmatch(r"[a-z0-9][a-z0-9._:-]{2,79}", token_ref): + fail("token_ref_invalid") + return { + "operation": "create", + "env": environment, + "principal_ref": principal_ref, + "principal_alias": alias, + "token_ref": token_ref, + } + + +def read_store(path: Path) -> tuple[bytes, dict[str, str]]: + if not path.exists(): + return b"", {} + raw_bytes = path.read_bytes() + try: + text = raw_bytes.decode("utf-8") + except UnicodeDecodeError: + fail("secret_store_encoding_invalid") + entries: dict[str, str] = {} + for line in text.splitlines(): + if not line.strip(): + continue + key, separator, token = line.partition(": ") + if ( + not separator + or not key + or key.strip() != key + or not token.startswith("iop_") + or token.strip() != token + or key in entries + or "\n" in token + or "\r" in token + ): + fail("secret_store_format_invalid") + entries[key] = token + return raw_bytes, entries + + +def write_candidate(path: Path, content: bytes) -> Path: + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + os.chmod(path.parent, 0o700) + candidate = ( + path.parent / f".{path.name}.candidate-{os.getpid()}-{secrets.token_hex(4)}" + ) + descriptor = os.open(candidate, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + with os.fdopen(descriptor, "wb") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + except BaseException: + candidate.unlink(missing_ok=True) + raise + return candidate + + +def atomic_restore(path: Path, content: bytes | None) -> None: + if content is None: + path.unlink(missing_ok=True) + return + candidate = write_candidate(path, content) + os.replace(candidate, path) + os.chmod(path, 0o600) + + +def sha256_text(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def curl_config_quote(value: str) -> str: + return ( + value.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\r", "") + .replace("\n", "") + ) + + +def curl_request( + root: Path, + method: str, + url: str, + headers: list[str], + payload: dict[str, Any] | None, + *, + timeout: int, +) -> tuple[int, bytes]: + marker = b"\n__IOP_HTTP_STATUS__" + config_lines = [ + "silent", + "show-error", + 'proto = "=https"', + f"max-time = {timeout}", + f'request = "{curl_config_quote(method)}"', + f'url = "{curl_config_quote(url)}"', + 'write-out = "\\n__IOP_HTTP_STATUS__%{http_code}"', + ] + config_lines.extend(f'header = "{curl_config_quote(header)}"' for header in headers) + argv = ["curl", "-q", "--config", "-"] + payload_path: Path | None = None + if payload is not None: + payload_bytes = json.dumps( + payload, ensure_ascii=False, separators=(",", ":") + ).encode("utf-8") + payload_path = write_candidate(root / "token/.curl-json-payload", payload_bytes) + argv.extend(["--data-binary", f"@{payload_path}"]) + try: + result = run_quiet( + argv, + cwd=root, + input_bytes=("\n".join(config_lines) + "\n").encode("utf-8"), + timeout=timeout + 5, + ) + finally: + if payload_path: + payload_path.unlink(missing_ok=True) + if result.returncode != 0: + raise CurlTransportFailure() + body, separator, status_bytes = result.stdout.rpartition(marker) + if not separator or not status_bytes.isdigit(): + raise CurlTransportFailure() + return int(status_bytes), body + + +def remote_call( + root: Path, + profile: dict[str, Any], + payload: dict[str, Any], + *, + timeout: int, +) -> dict[str, Any]: + helper = ( + root + / "agent-ops/skills/project/openai-usage-token-issue/scripts/remote_edge_transaction.rb" + ) + source = helper.read_text(encoding="utf-8") + remote_command = "ruby -e " + shlex.quote(source) + remote_payload = dict(payload) + remote_payload["environment"] = profile["environment"] + result = run_quiet( + [ + "ssh", + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=8", + str(profile["edge_ssh"]), + remote_command, + ], + cwd=root, + input_bytes=json.dumps(remote_payload, separators=(",", ":")).encode("utf-8"), + timeout=timeout, + ) + parsed: dict[str, Any] | None = None + for line in reversed(result.stdout.decode("utf-8", "replace").splitlines()): + try: + value = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(value, dict) and "status" in value: + parsed = value + break + if parsed is None: + fail("edge_remote_response_invalid") + if result.returncode != 0 or parsed.get("status") == "blocked": + code = str(parsed.get("code", "edge_remote_failed")) + if not re.fullmatch(r"[a-z0-9_]+", code): + code = "edge_remote_failed" + fail(code if code.startswith("edge_") else f"edge_{code}") + return parsed + + +class ConfluenceClient: + def __init__(self, root: Path, username: str, token: str): + self.root = root + self.username = username + self.token = token + + def request( + self, method: str, path_or_url: str, payload: dict[str, Any] | None = None + ) -> dict[str, Any]: + url = ( + path_or_url + if path_or_url.startswith("https://") + else ATLASSIAN_BASE_URL + path_or_url + ) + parsed_url = urllib.parse.urlsplit(url) + if ( + parsed_url.scheme != "https" + or parsed_url.netloc != "lgucorp.atlassian.net" + or not parsed_url.path.startswith("/wiki/") + ): + fail("confluence_url_invalid") + try: + status_code, data = curl_request( + self.root, + method, + url, + [ + f"Authorization: Basic {base64_basic(self.username, self.token)}", + "Accept: application/json", + "Content-Type: application/json", + ], + payload, + timeout=20, + ) + except CurlTransportFailure: + fail("confluence_network_failed") + if status_code < 200 or status_code >= 300: + raise ConfluenceHTTPFailure(status_code) + try: + parsed = json.loads(data) + except json.JSONDecodeError: + fail("confluence_response_invalid") + if not isinstance(parsed, dict): + fail("confluence_response_invalid") + return parsed + + +def base64_basic(username: str, token: str) -> str: + return base64.b64encode(f"{username}:{token}".encode()).decode("ascii") + + +def confluence_storage(page: dict[str, Any]) -> str: + body = page.get("body") + if not isinstance(body, dict): + fail("confluence_body_missing") + storage = body.get("storage") + if not isinstance(storage, dict) or not isinstance(storage.get("value"), str): + fail("confluence_body_missing") + return str(storage["value"]) + + +def page_version(page: dict[str, Any]) -> int: + version = page.get("version") + if not isinstance(version, dict) or not isinstance(version.get("number"), int): + fail("confluence_version_invalid") + return int(version["number"]) + + +def strip_storage_tags(value: str) -> str: + without_tags = re.sub(r"<[^>]+>", "", value) + return re.sub(r"\s+", " ", html.unescape(without_tags)).strip() + + +def managed_section_bounds(storage_body: str) -> tuple[int, int]: + heading_pattern = re.compile( + r"]*>.*?", re.IGNORECASE | re.DOTALL + ) + headings = list(heading_pattern.finditer(storage_body)) + matches = [ + item + for item in headings + if strip_storage_tags(item.group(0)) == MANAGED_HEADING + ] + if len(matches) != 1: + fail("confluence_managed_heading_invalid") + current = matches[0] + current_level = int(current.group(1)) + end = len(storage_body) + for candidate in headings: + if ( + candidate.start() > current.start() + and int(candidate.group(1)) <= current_level + ): + end = candidate.start() + break + return current.start(), end + + +def managed_section(mapping_rows: list[dict[str, str]], synchronized_at: str) -> str: + parts = [f"

{html.escape(MANAGED_HEADING)}

", ""] + parts.extend(f"" for header in TABLE_HEADERS) + parts.append("") + for item in sorted(mapping_rows, key=lambda row: row["principal_ref"]): + values = ( + item["principal_ref"], + item["principal_alias"], + item["token_ref"], + "active", + synchronized_at, + ) + parts.append("") + parts.extend(f"" for value in values) + parts.append("") + parts.append("
{html.escape(header)}
{html.escape(value)}
") + return "".join(parts) + + +def replace_managed_section(storage_body: str, replacement: str) -> str: + if not storage_body.strip(): + return replacement + start, end = managed_section_bounds(storage_body) + return storage_body[:start] + replacement + storage_body[end:] + + +class TableParser(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.rows: list[list[str]] = [] + self.current_row: list[str] | None = None + self.current_cell: list[str] | None = None + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + del attrs + if tag.lower() == "tr": + self.current_row = [] + elif tag.lower() in {"td", "th"} and self.current_row is not None: + self.current_cell = [] + + def handle_data(self, data: str) -> None: + if self.current_cell is not None: + self.current_cell.append(data) + + def handle_endtag(self, tag: str) -> None: + lowered = tag.lower() + if ( + lowered in {"td", "th"} + and self.current_cell is not None + and self.current_row is not None + ): + self.current_row.append( + re.sub(r"\s+", " ", "".join(self.current_cell)).strip() + ) + self.current_cell = None + elif lowered == "tr" and self.current_row is not None: + self.rows.append(self.current_row) + self.current_row = None + + +def parse_managed_table(storage_body: str) -> list[tuple[str, str, str, str, str]]: + start, end = managed_section_bounds(storage_body) + section = storage_body[start:end] + if "iop_" in section or re.search( + r"(? None: + records = parse_managed_table(storage_body) + expected = { + (item["principal_ref"], item["principal_alias"], item["token_ref"], "active") + for item in mapping_rows + } + actual = {record[:4] for record in records} + if len(records) != len(mapping_rows) or actual != expected: + fail("confluence_table_mismatch") + + +def ensure_create_preserves_rows( + existing_records: list[tuple[str, str, str, str, str]], + mapping_rows: list[dict[str, str]], +) -> None: + expected_principals = {item["principal_ref"] for item in mapping_rows} + existing_principals = {record[0] for record in existing_records} + if not existing_principals.issubset(expected_principals): + fail("confluence_create_would_remove_user") + + +def page_in_folder(client: ConfluenceClient, page: dict[str, Any]) -> bool: + parent_id = str(page.get("parentId") or "") + parent_type = str(page.get("parentType") or "") + seen: set[tuple[str, str]] = set() + for _ in range(20): + if parent_id == CONFLUENCE_FOLDER_ID: + return True + if not parent_id or (parent_type, parent_id) in seen: + return False + seen.add((parent_type, parent_id)) + if parent_type == "page": + parent = client.request( + "GET", f"/wiki/api/v2/pages/{urllib.parse.quote(parent_id)}" + ) + else: + parent = client.request( + "GET", f"/wiki/api/v2/folders/{urllib.parse.quote(parent_id)}" + ) + parent_id = str(parent.get("parentId") or "") + parent_type = str(parent.get("parentType") or "") + return False + + +def exact_title_pages(client: ConfluenceClient, space_id: str) -> list[dict[str, Any]]: + query = urllib.parse.urlencode( + { + "space-id": space_id, + "title": CONFLUENCE_TITLE, + "status": "current", + "limit": 250, + } + ) + next_url: str | None = f"/wiki/api/v2/pages?{query}" + matches: list[dict[str, Any]] = [] + while next_url: + response = client.request("GET", next_url) + results = response.get("results") + if not isinstance(results, list): + fail("confluence_search_invalid") + for result in results: + if ( + isinstance(result, dict) + and result.get("title") == CONFLUENCE_TITLE + and result.get("status") == "current" + ): + matches.append(result) + links = response.get("_links") + next_value = links.get("next") if isinstance(links, dict) else None + next_url = str(next_value) if next_value else None + return matches + + +def preflight_confluence( + root: Path, + client: ConfluenceClient, + target_cache: Path, + *, + read_only: bool, +) -> dict[str, Any]: + client.request("GET", "/wiki/rest/api/user/current") + folder = client.request("GET", f"/wiki/api/v2/folders/{CONFLUENCE_FOLDER_ID}") + space_id = str(folder.get("spaceId") or "") + if not space_id: + fail("confluence_space_invalid") + candidates = exact_title_pages(client, space_id) + valid: list[dict[str, Any]] = [] + for page_candidate in candidates: + page_id = str(page_candidate.get("id") or "") + if not page_id: + continue + detail = client.request( + "GET", + f"/wiki/api/v2/pages/{urllib.parse.quote(page_id)}?body-format=storage", + ) + if page_in_folder(client, detail): + valid.append(detail) + if len(valid) > 1: + fail("confluence_target_ambiguous") + if not valid: + if read_only: + fail("confluence_target_missing") + initial = managed_section( + [], time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + ) + created = client.request( + "POST", + "/wiki/api/v2/pages", + { + "spaceId": space_id, + "status": "current", + "title": CONFLUENCE_TITLE, + "parentId": CONFLUENCE_FOLDER_ID, + "body": {"representation": "storage", "value": initial}, + }, + ) + page_id = str(created.get("id") or "") + if not page_id: + fail("confluence_create_invalid") + valid = [ + client.request("GET", f"/wiki/api/v2/pages/{page_id}?body-format=storage") + ] + if not page_in_folder(client, valid[0]): + fail("confluence_parent_invalid") + page = valid[0] + if page.get("title") != CONFLUENCE_TITLE or page.get("status") != "current": + fail("confluence_target_invalid") + confluence_storage(page) + page_version(page) + if not read_only: + canonical = ( + f"{ATLASSIAN_BASE_URL}/wiki/spaces/Lab2/pages/{page['id']}\n".encode() + ) + target_candidate = write_candidate(target_cache, canonical) + os.replace(target_candidate, target_cache) + os.chmod(target_cache, 0o600) + require_secure_secret(root, target_cache) + return page + + +def table_rows_from_store( + store_entries: dict[str, str], remote_mappings: list[dict[str, Any]] +) -> list[dict[str, str]]: + rows: list[dict[str, str]] = [] + for principal_ref, raw_token in store_entries.items(): + matches = [ + item + for item in remote_mappings + if str(item.get("principal_ref")) == principal_ref + ] + if len(matches) != 1: + fail("store_edge_mapping_mismatch") + item = matches[0] + if sha256_text(raw_token) != str(item.get("token_hash_sha256", "")).lower(): + fail("store_edge_hash_mismatch") + rows.append( + { + "principal_ref": principal_ref, + "principal_alias": str(item.get("principal_alias", "")), + "token_ref": str(item.get("token_ref", "")), + } + ) + if len({row["principal_ref"] for row in rows}) != len(rows): + fail("store_edge_mapping_duplicate") + return rows + + +def sync_confluence( + client: ConfluenceClient, + page: dict[str, Any], + mapping_rows: list[dict[str, str]], +) -> tuple[int, int]: + page_id = str(page.get("id") or "") + latest = client.request( + "GET", f"/wiki/api/v2/pages/{urllib.parse.quote(page_id)}?body-format=storage" + ) + if not page_in_folder(client, latest): + fail("confluence_parent_invalid") + old_version = page_version(latest) + old_parent = str(latest.get("parentId") or "") + old_title = str(latest.get("title") or "") + old_status = str(latest.get("status") or "") + if old_title != CONFLUENCE_TITLE or old_status != "current": + fail("confluence_target_changed") + old_body = confluence_storage(latest) + records = parse_managed_table(old_body) + ensure_create_preserves_rows(records, mapping_rows) + try: + verify_managed_table(old_body, mapping_rows) + return old_version, len(mapping_rows) + except WorkflowFailure as error: + if error.code != "confluence_table_mismatch": + raise + synchronized_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + replacement = managed_section(mapping_rows, synchronized_at) + new_body = replace_managed_section(old_body, replacement) + client.request( + "PUT", + f"/wiki/api/v2/pages/{urllib.parse.quote(page_id)}", + { + "id": page_id, + "status": old_status, + "title": old_title, + "body": {"representation": "storage", "value": new_body}, + "version": {"number": old_version + 1}, + }, + ) + verified = client.request( + "GET", f"/wiki/api/v2/pages/{urllib.parse.quote(page_id)}?body-format=storage" + ) + if ( + str(verified.get("id")) != page_id + or str(verified.get("title")) != old_title + or str(verified.get("status")) != old_status + or str(verified.get("parentId") or "") != old_parent + or page_version(verified) != old_version + 1 + ): + fail("confluence_reread_mismatch") + verify_managed_table(confluence_storage(verified), mapping_rows) + return old_version + 1, len(mapping_rows) + + +def api_json( + root: Path, + profile: dict[str, Any], + path: str, + raw_token: str, + payload: dict[str, Any] | None = None, + timeout: int = 30, +) -> dict[str, Any]: + method = "POST" if payload is not None else "GET" + try: + status_code, data = curl_request( + root, + method, + str(profile["openai_base_url"]) + path, + [f"Authorization: Bearer {raw_token}", "Content-Type: application/json"], + payload, + timeout=timeout, + ) + except CurlTransportFailure: + fail("openai_smoke_network_failed") + if status_code < 200 or status_code >= 300: + fail("openai_smoke_http_failed") + try: + value = json.loads(data) + except json.JSONDecodeError: + fail("openai_smoke_response_invalid") + if not isinstance(value, dict): + fail("openai_smoke_response_invalid") + return value + + +def api_smoke(root: Path, profile: dict[str, Any], raw_token: str) -> None: + if profile["openai_smoke_transport"] == "ssh-loopback": + remote_call( + root, + profile, + {"action": "api-smoke", "raw_token": raw_token}, + timeout=135, + ) + return + if profile["openai_smoke_transport"] != "public-https": + fail("openai_smoke_transport_invalid") + models = api_json(root, profile, "/models", raw_token, timeout=15) + if not isinstance(models.get("data"), list) or not models["data"]: + fail("openai_models_invalid") + response = api_json( + root, + profile, + "/chat/completions", + raw_token, + { + "model": profile["smoke_model"], + "messages": [{"role": "user", "content": "Reply with the single word OK."}], + "max_tokens": 2048, + "temperature": 0, + }, + timeout=120, + ) + choices = response.get("choices") + if not isinstance(choices, list) or not choices or not isinstance(choices[0], dict): + fail("openai_chat_invalid") + message = choices[0].get("message") + content = message.get("content") if isinstance(message, dict) else None + if ( + not isinstance(content, str) + or not content.strip() + or not choices[0].get("finish_reason") + ): + fail("openai_chat_invalid") + + +def metrics_observed( + root: Path, profile: dict[str, Any], token_ref: str +) -> bool | None: + if profile["metrics_transport"] == "ssh-loopback": + try: + result = remote_call( + root, + profile, + {"action": "metrics", "token_ref": token_ref}, + timeout=15, + ) + except WorkflowFailure: + return None + observed = result.get("observed") + return observed if isinstance(observed, bool) else None + if profile["metrics_transport"] != "public-http": + return None + try: + with urllib.request.urlopen(str(profile["metrics_url"]), timeout=8) as response: + metrics = response.read().decode("utf-8", "replace") + except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError): + return None + return re.search(r'token_ref="' + re.escape(token_ref) + r'"', metrics) is not None + + +def tracked_leak_check(root: Path, raw_token: str) -> None: + files = run_quiet(["git", "ls-files", "-z"], cwd=root) + if files.returncode != 0: + fail("tracked_file_list_failed") + needle = raw_token.encode("utf-8") + for item in files.stdout.split(b"\0"): + if not item: + continue + path = root / os.fsdecode(item) + try: + if path.is_file() and needle in path.read_bytes(): + fail("raw_token_leak_detected") + except OSError: + fail("tracked_leak_check_failed") + + +def copy_once_to_clipboard(raw_token: str, root: Path) -> None: + copied = run_quiet( + ["/usr/bin/pbcopy"], cwd=root, input_bytes=raw_token.encode("utf-8") + ) + if copied.returncode != 0: + fail("clipboard_copy_failed") + pasted = run_quiet(["/usr/bin/pbpaste"], cwd=root) + if ( + pasted.returncode != 0 + or hashlib.sha256(pasted.stdout).digest() + != hashlib.sha256(raw_token.encode()).digest() + ): + fail("clipboard_verify_failed") + + +def load_confluence( + root: Path, profile: dict[str, Any] +) -> tuple[ConfluenceClient, Path]: + token_path = root / "token/.lgu-atlassian-token" + username_path = root / str(profile["confluence_user_store"]) + target_cache = root / "token/.dev-corp-iop-confluence-target" + require_secure_secret(root, token_path) + require_secure_secret(root, username_path) + token = token_path.read_text(encoding="utf-8").strip() + username = username_path.read_text(encoding="utf-8").strip() + if not token or "\n" in token or "\r" in token: + fail("atlassian_token_invalid") + if ( + not username + or "\n" in username + or "\r" in username + or not re.fullmatch(r"[^@\s]+@[^@\s]+", username) + ): + fail("atlassian_user_invalid") + if target_cache.exists(): + require_secure_secret(root, target_cache) + elif not git_is_ignored(root, target_cache): + fail("target_cache_ignore_invalid") + return ConfluenceClient(root, username, token), target_cache + + +def preflight(request: dict[str, str]) -> dict[str, Any]: + root = repo_root() + profile = load_profile(root, request["env"]) + store_path = root / str(profile["secret_store"]) + require_secure_secret(root, store_path, must_exist=False) + _store_bytes, store_entries = read_store(store_path) + remote = remote_call( + root, + profile, + {"action": "inspect", "refresh_probe": True}, + timeout=20, + ) + mappings = remote.get("mappings") + if ( + not isinstance(mappings, list) + or not remote.get("candidate_shape") + or remote.get("refresh_status") != "applied" + or not remote.get("healthy") + or not remote.get("listener") + ): + fail("edge_preflight_invalid") + remote_mappings = [item for item in mappings if isinstance(item, dict)] + confluence_status = "not-applicable" + if profile["confluence_enabled"]: + client, target_cache = load_confluence(root, profile) + page = preflight_confluence(root, client, target_cache, read_only=True) + current_rows = table_rows_from_store(store_entries, remote_mappings) + ensure_create_preserves_rows( + parse_managed_table(confluence_storage(page)), current_rows + ) + confluence_status = "ready" + request_state = ( + "existing" + if select_existing(request, store_entries, remote_mappings) + else "new" + ) + return { + "result": "ready", + "mode": "preflight", + "environment": request["env"], + "edge": "healthy", + "confluence": confluence_status, + "stored_user_count": len(store_entries), + "request_state": request_state, + "raw_token_generated": False, + } + + +def select_existing( + request: dict[str, str], + store_entries: dict[str, str], + remote_mappings: list[dict[str, Any]], +) -> tuple[dict[str, Any], str] | None: + principal_matches = [ + item + for item in remote_mappings + if str(item.get("principal_ref")) == request["principal_ref"] + ] + ref_matches = [ + item + for item in remote_mappings + if str(item.get("token_ref")) == request["token_ref"] + ] + local_token = store_entries.get(request["principal_ref"]) + if len(principal_matches) > 1 or len(ref_matches) > 1: + fail("existing_mapping_ambiguous") + if ref_matches and ( + not principal_matches or ref_matches[0] is not principal_matches[0] + ): + fail("token_ref_conflict") + if principal_matches: + if local_token is None: + fail("existing_mapping_missing_local_token") + mapping = principal_matches[0] + if ( + sha256_text(local_token) + != str(mapping.get("token_hash_sha256", "")).lower() + ): + fail("existing_mapping_hash_mismatch") + return mapping, local_token + if local_token is not None: + fail("local_token_missing_edge_mapping") + return None + + +def execute(request: dict[str, str]) -> dict[str, Any]: + started = time.monotonic() + root = repo_root() + profile = load_profile(root, request["env"]) + store_path = root / str(profile["secret_store"]) + require_secure_secret(root, store_path, must_exist=False) + old_store_bytes, store_entries = read_store(store_path) + client: ConfluenceClient | None = None + target_cache: Path | None = None + page: dict[str, Any] | None = None + + print("[1/6] preflight", file=sys.stderr, flush=True) + if profile["confluence_enabled"]: + client, target_cache = load_confluence(root, profile) + page = preflight_confluence(root, client, target_cache, read_only=False) + remote = remote_call( + root, + profile, + {"action": "inspect", "refresh_probe": True}, + timeout=20, + ) + mappings_value = remote.get("mappings") + if ( + not isinstance(mappings_value, list) + or not remote.get("candidate_shape") + or remote.get("refresh_status") != "applied" + or not remote.get("healthy") + or not remote.get("listener") + ): + fail("edge_preflight_invalid") + remote_mappings = [item for item in mappings_value if isinstance(item, dict)] + if page is not None: + current_rows = table_rows_from_store(store_entries, remote_mappings) + ensure_create_preserves_rows( + parse_managed_table(confluence_storage(page)), current_rows + ) + existing = select_existing(request, store_entries, remote_mappings) + + edge_state = "existing-verified" + backup_path: str | None = None + raw_token: str + if existing: + mapping, raw_token = existing + request["principal_alias"] = str( + mapping.get("principal_alias") or request["principal_alias"] + ) + request["token_ref"] = str(mapping.get("token_ref") or request["token_ref"]) + print("[2/6] existing mapping reused", file=sys.stderr, flush=True) + else: + print("[2/6] token generated in memory", file=sys.stderr, flush=True) + raw_token = "iop_" + secrets.token_urlsafe(36) + token_hash = sha256_text(raw_token) + new_content = old_store_bytes + if new_content and not new_content.endswith(b"\n"): + new_content += b"\n" + new_content += f"{request['principal_ref']}: {raw_token}\n".encode() + local_candidate = write_candidate(store_path, new_content) + try: + print("[3/6] Edge transaction", file=sys.stderr, flush=True) + activated = remote_call( + root, + profile, + { + "action": "apply", + "entry": { + "token_ref": request["token_ref"], + "token_hash_sha256": token_hash, + "principal_ref": request["principal_ref"], + "principal_alias": request["principal_alias"], + }, + }, + timeout=90, + ) + backup_path = str(activated.get("backup_path") or "") + if not backup_path: + fail("edge_backup_reference_missing") + os.replace(local_candidate, store_path) + os.chmod(store_path, 0o600) + require_secure_secret(root, store_path) + edge_state = "activated" + except BaseException: + if backup_path: + rollback = remote_call( + root, + profile, + {"action": "rollback", "backup_path": backup_path}, + timeout=70, + ) + if rollback.get("status") != "rolled_back": + fail("edge_rollback_unconfirmed") + atomic_restore(store_path, old_store_bytes if old_store_bytes else None) + raise + finally: + local_candidate.unlink(missing_ok=True) + + print("[4/6] environment API smoke", file=sys.stderr, flush=True) + try: + api_smoke(root, profile, raw_token) + except WorkflowFailure: + if backup_path: + rollback = remote_call( + root, + profile, + {"action": "rollback", "backup_path": backup_path}, + timeout=70, + ) + if rollback.get("status") != "rolled_back": + fail("edge_rollback_unconfirmed") + atomic_restore(store_path, old_store_bytes if old_store_bytes else None) + raise + + version: int | None = None + confluence_status = "not-applicable" + _current_bytes, current_store = read_store(store_path) + row_count = len(current_store) + if client is not None and page is not None: + print("[5/6] Confluence metadata sync", file=sys.stderr, flush=True) + current_remote = remote_call(root, profile, {"action": "inspect"}, timeout=20) + current_values = current_remote.get("mappings") + if not isinstance(current_values, list): + fail("edge_mapping_reread_invalid") + rows = table_rows_from_store( + current_store, [item for item in current_values if isinstance(item, dict)] + ) + version, row_count = sync_confluence(client, page, rows) + confluence_status = "updated" + else: + print("[5/6] Confluence not applicable", file=sys.stderr, flush=True) + + print("[6/6] leak check and one-time delivery", file=sys.stderr, flush=True) + tracked_leak_check(root, raw_token) + require_secure_secret(root, store_path) + if target_cache is not None: + require_secure_secret(root, target_cache) + copy_once_to_clipboard(raw_token, root) + observed = metrics_observed(root, profile, request["token_ref"]) + return { + "result": "completed", + "operation": "create", + "environment": request["env"], + "principal_alias": request["principal_alias"], + "token_ref": request["token_ref"], + "edge_mapping": edge_state, + "api_smoke": "passed", + "confluence_metadata_sync": confluence_status, + "confluence_version": version, + "metadata_row_count": row_count, + "metrics_observed": observed, + "raw_token_delivered_once": True, + "delivery_channel": "local_clipboard", + "leak_check": "passed", + "elapsed_seconds": round(time.monotonic() - started, 1), + } + + +def selftest() -> dict[str, Any]: + sample = normalize_alias("A.Name+test@example.invalid", None) + if sample != "a-name-test": + fail("selftest_alias_failed") + if normalize_alias("a@example.invalid", None) != "a": + fail("selftest_short_alias_failed") + try: + parse_request('{"env":"dev","principal_ref":null}') + except WorkflowFailure as error: + if error.code != "principal_ref_invalid": + raise + else: + fail("selftest_input_type_failed") + try: + parse_request('{"principal_ref":"sample@example.invalid"}') + except WorkflowFailure as error: + if error.code != "environment_not_supported": + raise + else: + fail("selftest_environment_required_failed") + root = repo_root() + dev_request = parse_request( + '{"env":"dev","principal_ref":"sample@example.invalid"}' + ) + corp_request = parse_request( + '{"env":"dev-corp","principal_ref":"sample@example.invalid"}' + ) + if dev_request["token_ref"] != "iop-dev-sample": + fail("selftest_dev_token_ref_failed") + if corp_request["token_ref"] != "iop-dev-corp-sample": + fail("selftest_dev_corp_token_ref_failed") + for environment in SUPPORTED_ENVIRONMENTS: + profile = load_profile(root, environment) + if profile["environment"] != environment: + fail("selftest_profile_failed") + rows = [ + { + "principal_ref": "sample@example.invalid", + "principal_alias": "sample", + "token_ref": "iop-dev-corp-sample", + } + ] + original = "

Intro

Keep

IOP 사용자 토큰 발급 현황

old

Next

Keep too

" + replaced = replace_managed_section( + original, managed_section(rows, "2026-01-01T00:00:00Z") + ) + verify_managed_table(replaced, rows) + if "

Keep

" not in replaced or "

Keep too

" not in replaced: + fail("selftest_section_failed") + records = parse_managed_table(replaced) + try: + ensure_create_preserves_rows(records, []) + except WorkflowFailure as error: + if error.code != "confluence_create_would_remove_user": + raise + else: + fail("selftest_append_only_failed") + helper = ( + root + / "agent-ops/skills/project/openai-usage-token-issue/scripts/remote_edge_transaction.rb" + ) + for environment in SUPPORTED_ENVIRONMENTS: + ruby = run_quiet( + ["ruby", str(helper)], + cwd=root, + input_bytes=json.dumps( + {"action": "selftest", "environment": environment}, + separators=(",", ":"), + ).encode(), + timeout=10, + ) + parsed = None + for line in reversed(ruby.stdout.decode("utf-8", "replace").splitlines()): + try: + value = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(value, dict): + parsed = value + break + if ruby.returncode != 0 or not parsed or parsed.get("status") != "ok": + fail("selftest_remote_helper_failed") + return {"result": "passed", "mode": "self-test", "network_used": False} + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Issue one dev or dev-corp IOP user token from stdin JSON." + ) + mode = parser.add_mutually_exclusive_group() + mode.add_argument( + "--preflight", action="store_true", help="Run read-only live readiness checks." + ) + mode.add_argument( + "--self-test", action="store_true", help="Run local deterministic tests." + ) + arguments = parser.parse_args() + try: + if arguments.self_test: + result = selftest() + else: + request = parse_request(sys.stdin.read()) + root = repo_root() + with local_transaction_lock(root, shared=arguments.preflight): + result = preflight(request) if arguments.preflight else execute(request) + print(json.dumps(result, ensure_ascii=False, separators=(",", ":"))) + return 0 + except WorkflowFailure as error: + print( + json.dumps( + { + "result": "blocked", + "code": error.code, + "raw_token_reported": False, + "retry_same_command": True, + }, + ensure_ascii=False, + separators=(",", ":"), + ) + ) + return 2 + except KeyboardInterrupt: + print( + json.dumps( + { + "result": "blocked", + "code": "interrupted", + "raw_token_reported": False, + "retry_same_command": True, + }, + separators=(",", ":"), + ) + ) + return 130 + except Exception: # noqa: BLE001 - never render a traceback that could retain secret request state. + print( + json.dumps( + { + "result": "blocked", + "code": "unexpected_local_failure", + "raw_token_reported": False, + "retry_same_command": True, + }, + separators=(",", ":"), + ) + ) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/agent-ops/skills/project/openai-usage-token-issue/scripts/remote_edge_transaction.rb b/agent-ops/skills/project/openai-usage-token-issue/scripts/remote_edge_transaction.rb new file mode 100644 index 00000000..f4d2776a --- /dev/null +++ b/agent-ops/skills/project/openai-usage-token-issue/scripts/remote_edge_transaction.rb @@ -0,0 +1,618 @@ +# frozen_string_literal: true + +require "digest" +require "fileutils" +require "json" +require "net/http" +require "open3" +require "securerandom" +require "time" +require "tmpdir" +require "yaml" + +NOHUP_BIN = "/usr/bin/nohup" +PROFILES = { + "dev" => { + "environment" => "dev", + "config_path" => "/Users/toki/agent-work/iop-dev/build/dev-runtime/edge.yaml", + "config_arg" => "build/dev-runtime/edge.yaml", + "edge_bin" => "/Users/toki/agent-work/iop-dev/build/dev-runtime/bin/edge", + "runtime_root" => "/Users/toki/agent-work/iop-dev", + "admin_addr" => "127.0.0.1:19093", + "health_url" => "http://127.0.0.1:18083/healthz", + "listener_port" => 18_083, + "log_path" => "/Users/toki/agent-work/iop-dev/build/dev-runtime/logs/iop-edge-token-issue.log", + "lock_path" => "/Users/toki/agent-work/iop-dev/build/dev-runtime/.token-issue.lock", + "api_base_url_runner" => "http://127.0.0.1:18083/v1", + "metrics_url_runner" => "http://127.0.0.1:19101/metrics", + "smoke_model" => "laguna-s:2.1" + }, + "dev-corp" => { + "environment" => "dev-corp", + "config_path" => "/Users/toki/agent-work/iop-dev-corp/build/dev-corp-runtime/edge.yaml", + "config_arg" => "/Users/toki/agent-work/iop-dev-corp/build/dev-corp-runtime/edge.yaml", + "edge_bin" => "/Users/toki/agent-work/iop-dev-corp/build/dev-corp-runtime/bin/iop-edge", + "runtime_root" => "/Users/toki/agent-work/iop-dev-corp", + "admin_addr" => "127.0.0.1:19094", + "health_url" => "http://127.0.0.1:18086/healthz", + "listener_port" => 18_086, + "log_path" => "/Users/toki/agent-work/iop-dev-corp/build/dev-corp-runtime/logs/iop-edge-token-issue.log", + "lock_path" => "/Users/toki/agent-work/iop-dev-corp/build/dev-corp-runtime/.token-issue.lock", + "api_base_url_runner" => "http://127.0.0.1:18086/v1", + "metrics_url_runner" => "http://127.0.0.1:19105/metrics", + "smoke_model" => "ornith:35b" + } +}.freeze + +def profile + $profile || fail_safe("environment_missing") +end + +def config_path + profile.fetch("config_path") +end + +def config_arg + profile.fetch("config_arg") +end + +def edge_bin + profile.fetch("edge_bin") +end + +def runtime_root + profile.fetch("runtime_root") +end + +def admin_addr + profile.fetch("admin_addr") +end + +def health_uri + URI(profile.fetch("health_url")) +end + +def listener_port + Integer(profile.fetch("listener_port")) +end + +def log_path + profile.fetch("log_path") +end + +def lock_path + profile.fetch("lock_path") +end + +def expected_command + "#{edge_bin} --config #{config_arg} serve" +end + +class SafeFailure < StandardError + attr_reader :code + + def initialize(code) + @code = code + super(code) + end +end + +def fail_safe(code) + raise SafeFailure, code +end + +def with_transaction_lock + File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock| + File.chmod(0o600, lock_path) + fail_safe("transaction_busy") unless lock.flock(File::LOCK_EX | File::LOCK_NB) + begin + yield + ensure + lock.flock(File::LOCK_UN) + end + end +end + +def read_payload + payload = JSON.parse($stdin.read) + fail_safe("invalid_action") unless %w[inspect candidate-check apply rollback api-smoke metrics selftest].include?(payload["action"]) + environment = payload["environment"] + fail_safe("environment_invalid") unless environment.is_a?(String) && PROFILES.key?(environment) + payload +rescue JSON::ParserError + fail_safe("invalid_json") +end + +def load_config(path = config_path) + value = YAML.safe_load(File.read(path), aliases: true) + fail_safe("invalid_config_root") unless value.is_a?(Hash) + value +rescue Errno::ENOENT + fail_safe("config_missing") +rescue Psych::Exception + fail_safe("config_yaml_invalid") +end + +def mappings(config) + items = config.dig("openai", "principal_tokens") || [] + fail_safe("principal_tokens_invalid") unless items.is_a?(Array) && items.all? { |item| item.is_a?(Hash) } + items +end + +def validate_mappings!(items) + refs = items.map { |item| item["token_ref"].to_s } + hashes = items.map { |item| item["token_hash_sha256"].to_s.downcase } + fail_safe("mapping_token_ref_invalid") if refs.any?(&:empty?) || refs.uniq.length != refs.length + fail_safe("mapping_hash_invalid") if hashes.any? { |value| !value.match?(/\A[0-9a-f]{64}\z/) } || hashes.uniq.length != hashes.length + fail_safe("mapping_principal_invalid") if items.any? { |item| item["principal_ref"].to_s.empty? } +end + +def json_scalar(value) + JSON.generate(value.to_s) +end + +def insert_mapping(source, entry) + lines = source.lines + openai_index = lines.index { |line| line.match?(/\Aopenai:\s*(?:#.*)?\z/) } + fail_safe("openai_section_missing") unless openai_index + + openai_end = ((openai_index + 1)...lines.length).find do |index| + lines[index].match?(/\A\S/) && !lines[index].lstrip.start_with?("#") + end || lines.length + token_index = ((openai_index + 1)...openai_end).find do |index| + lines[index].match?(/\A principal_tokens:\s*(?:\[\])?\s*(?:#.*)?\z/) + end + fail_safe("principal_tokens_section_missing") unless token_index + + if lines[token_index].match?(/principal_tokens:\s*\[\]/) + lines[token_index] = " principal_tokens:\n" + token_end = token_index + 1 + sequence_indent = 4 + else + token_end = ((token_index + 1)...openai_end).find do |index| + lines[index].match?(/\A [A-Za-z0-9_][A-Za-z0-9_-]*:/) + end || openai_end + first_item = lines[(token_index + 1)...token_end].find { |line| line.match?(/\A\s*-\s+token_ref:/) } + fail_safe("principal_tokens_style_invalid") unless first_item + sequence_indent = first_item[/\A\s*/].length + end + sequence_prefix = " " * sequence_indent + field_prefix = " " * (sequence_indent + 2) + fragment = [ + "#{sequence_prefix}- token_ref: #{json_scalar(entry.fetch("token_ref"))}\n", + "#{field_prefix}token_hash_sha256: #{json_scalar(entry.fetch("token_hash_sha256"))}\n", + "#{field_prefix}principal_ref: #{json_scalar(entry.fetch("principal_ref"))}\n", + "#{field_prefix}principal_alias: #{json_scalar(entry.fetch("principal_alias"))}\n" + ] + lines.insert(token_end, *fragment) + lines.join +end + +def run_command(*argv) + stdout, _stderr, status = Open3.capture3(*argv) + fail_safe("command_failed") unless status.success? + stdout +end + +def loopback_uri(profile_key, path = nil) + base = profile.fetch(profile_key) + uri = URI(path ? "#{base}#{path}" : base) + fail_safe("loopback_url_invalid") unless uri.scheme == "http" && %w[127.0.0.1 ::1 localhost].include?(uri.host) + fail_safe("loopback_url_invalid") if uri.user || uri.password || uri.fragment + uri +end + +def loopback_request(uri, request, read_timeout:) + http = Net::HTTP.new(uri.host, uri.port, nil) + http.open_timeout = 3 + http.read_timeout = read_timeout + response = http.start { |client| client.request(request) } + fail_safe("loopback_http_failed") unless response.is_a?(Net::HTTPSuccess) + response.body.to_s +rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, SystemCallError + fail_safe("loopback_network_failed") +end + +def api_json(path, raw_token, payload = nil, read_timeout: 30) + uri = loopback_uri("api_base_url_runner", path) + request = payload ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri) + request["Authorization"] = "Bearer #{raw_token}" + request["Content-Type"] = "application/json" + request.body = JSON.generate(payload) if payload + body = loopback_request(uri, request, read_timeout: read_timeout) + parsed = JSON.parse(body) + fail_safe("openai_response_invalid") unless parsed.is_a?(Hash) + parsed +rescue JSON::ParserError + fail_safe("openai_response_invalid") +end + +def api_smoke(payload) + raw_token = payload["raw_token"] + fail_safe("raw_token_invalid") unless raw_token.is_a?(String) && raw_token.start_with?("iop_") && !raw_token.match?(/[\r\n]/) + models = api_json("/models", raw_token, nil, read_timeout: 15) + fail_safe("openai_models_invalid") unless models["data"].is_a?(Array) && !models["data"].empty? + response = api_json( + "/chat/completions", + raw_token, + { + "model" => profile.fetch("smoke_model"), + "messages" => [{ "role" => "user", "content" => "Reply with the single word OK." }], + "max_tokens" => 2048, + "temperature" => 0 + }, + read_timeout: 120 + ) + choice = response["choices"].is_a?(Array) ? response["choices"].first : nil + message = choice.is_a?(Hash) ? choice["message"] : nil + content = message.is_a?(Hash) ? message["content"] : nil + fail_safe("openai_chat_invalid") unless content.is_a?(String) && !content.strip.empty? && choice["finish_reason"] + { "status" => "passed" } +end + +def metrics_observed(payload) + token_ref = payload["token_ref"] + fail_safe("token_ref_invalid") unless token_ref.is_a?(String) && token_ref.match?(/\A[a-z0-9][a-z0-9._:-]{2,79}\z/) + uri = loopback_uri("metrics_url_runner") + body = loopback_request(uri, Net::HTTP::Get.new(uri), read_timeout: 8) + { "status" => "ok", "observed" => body.match?(/token_ref="#{Regexp.escape(token_ref)}"/) } +end + +def parse_refresh_status(stdout) + parsed = begin + value = JSON.parse(stdout) + value if value.is_a?(Hash) && value.key?("status") + rescue JSON::ParserError + nil + end + unless parsed + stdout.lines.reverse_each do |line| + begin + value = JSON.parse(line) + if value.is_a?(Hash) && value.key?("status") + parsed = value + break + end + rescue JSON::ParserError + next + end + end + end + fail_safe("refresh_response_invalid") unless parsed + parsed.fetch("status").to_s +end + +def refresh_status(candidate_path) + stdout = run_command( + edge_bin, + "--config", config_path, + "config", "refresh", + "--addr", admin_addr, + "--config-path", candidate_path, + "--mode", "dry-run" + ) + parse_refresh_status(stdout) +end + +def listener_pid + stdout, _stderr, status = Open3.capture3("lsof", "-tiTCP:#{listener_port}", "-sTCP:LISTEN") + return nil unless status.success? + values = stdout.lines.map(&:strip).reject(&:empty?).uniq + fail_safe("edge_listener_ambiguous") unless values.length == 1 + value = values.first + fail_safe("edge_listener_pid_invalid") unless value.match?(/\A\d+\z/) + value.to_i +end + +def expected_listener_pid + pid = listener_pid + return nil unless pid + stdout, _stderr, status = Open3.capture3("ps", "-p", pid.to_s, "-o", "command=") + fail_safe("edge_listener_identity_unavailable") unless status.success? + fail_safe("edge_listener_unexpected") unless stdout.strip == expected_command + pid +end + +def healthy? + uri = health_uri + response = Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 2) do |http| + http.get(uri.request_uri) + end + response.is_a?(Net::HTTPSuccess) +rescue StandardError + false +end + +def wait_until(timeout_seconds) + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds + loop do + return true if yield + return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline + sleep 0.25 + end +end + +def stop_edge + pid = expected_listener_pid + return unless pid + + Process.kill("TERM", pid) + return if wait_until(10) { expected_listener_pid != pid } + + Process.kill("KILL", pid) + fail_safe("edge_stop_failed") unless wait_until(5) { expected_listener_pid != pid } +rescue Errno::ESRCH + nil +rescue Errno::EPERM + fail_safe("edge_stop_denied") +end + +def start_edge + FileUtils.mkdir_p(File.dirname(log_path)) + log = File.open(log_path, "a", 0o600) + File.chmod(0o600, log_path) + begin + pid = Process.spawn( + NOHUP_BIN, edge_bin, "--config", config_arg, "serve", + chdir: runtime_root, + in: File::NULL, + out: log, + err: log, + pgroup: true + ) + ensure + log.close + end + Process.detach(pid) + fail_safe("edge_readiness_failed") unless wait_until(30) { expected_listener_pid == pid && healthy? } +end + +def restart_edge + stop_edge + start_edge +end + +def cutover_files(config_path, candidate_path, backup_path) + File.rename(config_path, backup_path) + begin + File.rename(candidate_path, config_path) + rescue StandardError + File.rename(backup_path, config_path) if File.file?(backup_path) && !File.exist?(config_path) + raise + end +end + +def rollback_to(backup_path) + expanded = File.expand_path(backup_path.to_s) + allowed_prefix = File.dirname(config_path) + File::SEPARATOR + fail_safe("backup_path_invalid") unless expanded.start_with?(allowed_prefix) + fail_safe("backup_path_invalid") unless File.basename(expanded).match?(/\Aedge\.yaml\.before-token-\d{8}T\d{6}Z-[0-9a-f]{6}\.yaml\z/) + fail_safe("backup_missing") unless File.file?(expanded) + run_command(edge_bin, "--config", expanded, "config", "check") + + failed_path = "#{config_path}.failed-token-#{Time.now.utc.strftime("%Y%m%dT%H%M%SZ")}-#{SecureRandom.hex(3)}.yaml" + stop_edge + File.rename(config_path, failed_path) if File.exist?(config_path) + File.rename(expanded, config_path) + start_edge + fail_safe("rollback_config_invalid") unless refresh_status(config_path) == "applied" + { "status" => "rolled_back" } +end + +def inspect_state(payload) + fail_safe("runtime_assets_invalid") unless File.executable?(edge_bin) && File.executable?(NOHUP_BIN) + fail_safe("runtime_directory_not_writable") unless File.writable?(File.dirname(config_path)) + config = load_config + items = mappings(config) + validate_mappings!(items) + probe_entry = { + "token_ref" => "iop-#{profile.fetch("environment")}-preflight-#{SecureRandom.hex(6)}", + "token_hash_sha256" => Digest::SHA256.hexdigest(SecureRandom.random_bytes(32)), + "principal_ref" => "preflight-shape.invalid", + "principal_alias" => "preflight-shape" + } + probe_config = YAML.safe_load(insert_mapping(File.read(config_path), probe_entry), aliases: true) + probe_items = mappings(probe_config) + validate_mappings!(probe_items) + fail_safe("candidate_shape_invalid") unless probe_items.length == items.length + 1 + current_refresh = payload["refresh_probe"] ? refresh_status(config_path) : nil + fail_safe("current_refresh_invalid") if current_refresh && current_refresh != "applied" + { + "status" => "ok", + "mapping_count" => items.length, + "mappings" => items.map do |item| + { + "token_ref" => item["token_ref"].to_s, + "token_hash_sha256" => item["token_hash_sha256"].to_s.downcase, + "principal_ref" => item["principal_ref"].to_s, + "principal_alias" => item["principal_alias"].to_s + } + end, + "candidate_shape" => true, + "refresh_status" => current_refresh, + "healthy" => healthy?, + "listener" => !expected_listener_pid.nil? + } +end + +def candidate_check + active_items = mappings(load_config) + probe_entry = { + "token_ref" => "iop-#{profile.fetch("environment")}-candidate-check-#{SecureRandom.hex(6)}", + "token_hash_sha256" => Digest::SHA256.hexdigest(SecureRandom.random_bytes(32)), + "principal_ref" => "candidate-check.invalid", + "principal_alias" => "candidate-check" + } + candidate_path = File.join(File.dirname(config_path), ".edge-token-candidate-check-#{SecureRandom.hex(6)}.yaml") + begin + File.open(candidate_path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file| + file.write(insert_mapping(File.read(config_path), probe_entry)) + file.flush + file.fsync + end + candidate_items = mappings(load_config(candidate_path)) + validate_mappings!(candidate_items) + fail_safe("candidate_count_invalid") unless candidate_items.length == active_items.length + 1 + run_command(edge_bin, "--config", candidate_path, "config", "check") + fail_safe("restart_required_not_reported") unless refresh_status(candidate_path) == "restart_required" + { "status" => "validated" } + ensure + File.delete(candidate_path) if File.exist?(candidate_path) + end +end + +def apply(payload) + entry = payload.fetch("entry") + fail_safe("entry_invalid") unless entry.is_a?(Hash) + fail_safe("hash_invalid") unless entry["token_hash_sha256"].to_s.match?(/\A[0-9a-f]{64}\z/) + %w[token_ref principal_ref principal_alias].each do |key| + value = entry[key].to_s + fail_safe("entry_invalid") if value.empty? || value.include?("\n") || value.include?("\r") + end + + active = load_config + active_items = mappings(active) + validate_mappings!(active_items) + fail_safe("principal_conflict") if active_items.any? { |item| item["principal_ref"].to_s == entry["principal_ref"].to_s } + fail_safe("token_ref_conflict") if active_items.any? { |item| item["token_ref"].to_s == entry["token_ref"].to_s } + fail_safe("token_hash_conflict") if active_items.any? { |item| item["token_hash_sha256"].to_s.casecmp?(entry["token_hash_sha256"].to_s) } + + candidate_path = nil + candidate_items = nil + backup_path = nil + original_moved = false + begin + source = File.read(config_path) + candidate_path = File.join( + File.dirname(config_path), + ".edge-token-issue-#{Time.now.utc.strftime("%Y%m%dT%H%M%SZ")}-#{SecureRandom.hex(3)}.yaml" + ) + File.open(candidate_path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file| + file.write(insert_mapping(source, entry)) + file.flush + file.fsync + end + + candidate = load_config(candidate_path) + candidate_items = mappings(candidate) + validate_mappings!(candidate_items) + fail_safe("candidate_count_invalid") unless candidate_items.length == active_items.length + 1 + run_command(edge_bin, "--config", candidate_path, "config", "check") + fail_safe("restart_required_not_reported") unless refresh_status(candidate_path) == "restart_required" + + backup_path = "#{config_path}.before-token-#{Time.now.utc.strftime("%Y%m%dT%H%M%SZ")}-#{SecureRandom.hex(3)}.yaml" + cutover_files(config_path, candidate_path, backup_path) + original_moved = true + candidate_path = nil + restart_edge + current = mappings(load_config) + validate_mappings!(current) + expected = current.count do |item| + item["principal_ref"].to_s == entry["principal_ref"].to_s && + item["token_ref"].to_s == entry["token_ref"].to_s && + item["token_hash_sha256"].to_s.casecmp?(entry["token_hash_sha256"].to_s) + end + fail_safe("active_mapping_invalid") unless expected == 1 + fail_safe("post_restart_refresh_invalid") unless refresh_status(config_path) == "applied" + rescue StandardError + if original_moved && backup_path && File.file?(backup_path) + begin + rollback_to(backup_path) + rescue StandardError + fail_safe("automatic_rollback_failed") + end + end + raise + ensure + File.delete(candidate_path) if candidate_path && File.exist?(candidate_path) + end + + { + "status" => "activated", + "backup_path" => backup_path, + "mapping_count" => candidate_items.length + } +end + +def selftest + PROFILES.each_value do |item| + fail_safe("selftest_profile_path_failed") unless item.fetch("config_path").start_with?(item.fetch("runtime_root") + File::SEPARATOR) + fail_safe("selftest_profile_command_failed") unless item.fetch("edge_bin").start_with?(item.fetch("runtime_root") + File::SEPARATOR) + uri = URI(item.fetch("api_base_url_runner")) + fail_safe("selftest_profile_url_failed") unless uri.scheme == "http" && uri.host == "127.0.0.1" + end + sample = <<~YAML + version: 1 + openai: + enabled: true + principal_tokens: [] + timeout_sec: 30 + YAML + entry = { + "token_ref" => "iop-dev-corp-sample", + "token_hash_sha256" => "a" * 64, + "principal_ref" => "sample.invalid", + "principal_alias" => "sample" + } + inserted = YAML.safe_load(insert_mapping(sample, entry), aliases: true) + items = mappings(inserted) + validate_mappings!(items) + fail_safe("selftest_insert_failed") unless items.length == 1 + indentless = <<~YAML + version: 1 + openai: + enabled: true + principal_tokens: + - token_ref: "existing" + token_hash_sha256: "#{"b" * 64}" + principal_ref: "existing.invalid" + principal_alias: "existing" + a2a: + enabled: false + YAML + indentless_inserted = insert_mapping(indentless, entry) + fail_safe("selftest_indentless_style_failed") unless indentless_inserted.include?("\n - token_ref: \"iop-dev-corp-sample\"\n") + indentless_items = mappings(YAML.safe_load(indentless_inserted, aliases: true)) + validate_mappings!(indentless_items) + fail_safe("selftest_indentless_insert_failed") unless indentless_items.length == 2 + applied = JSON.pretty_generate({ "status" => "applied", "restart_required_paths" => ["openai.principal_tokens"] }) + fail_safe("selftest_status_failed") unless parse_refresh_status(applied) == "applied" + Dir.mktmpdir("iop-token-cutover-") do |directory| + config_path = File.join(directory, "edge.yaml") + missing_candidate = File.join(directory, "missing.yaml") + backup_path = File.join(directory, "edge.yaml.backup") + File.write(config_path, "original") + begin + cutover_files(config_path, missing_candidate, backup_path) + fail_safe("selftest_cutover_failed") + rescue Errno::ENOENT + fail_safe("selftest_cutover_restore_failed") unless File.read(config_path) == "original" + fail_safe("selftest_cutover_backup_leaked") if File.exist?(backup_path) + end + end + { "status" => "ok" } +end + +begin + payload = read_payload + $profile = PROFILES.fetch(payload.fetch("environment")) + result = case payload.fetch("action") + when "inspect" then inspect_state(payload) + when "candidate-check" then with_transaction_lock { candidate_check } + when "apply" then with_transaction_lock { apply(payload) } + when "rollback" then with_transaction_lock { rollback_to(payload.fetch("backup_path")) } + when "api-smoke" then api_smoke(payload) + when "metrics" then metrics_observed(payload) + when "selftest" then selftest + end + puts JSON.generate(result) +rescue SafeFailure => error + puts JSON.generate({ "status" => "blocked", "code" => error.code }) + exit 2 +rescue KeyError + puts JSON.generate({ "status" => "blocked", "code" => "missing_input" }) + exit 2 +rescue StandardError + puts JSON.generate({ "status" => "blocked", "code" => "unexpected_remote_failure" }) + exit 2 +end