Compare commits

...
Author SHA1 Message Date
aios 42956a9de2 fix-gitops-actions-write-auth 2026-07-06 22:02:45 +08:00
9 changed files with 668 additions and 19 deletions
+100 -13
View File
@@ -84,14 +84,39 @@ def token_records():
else:
out.append({'account':'legacy-token','role':'owner','scopes':['*'],'token_plain':line})
return out
def normalize_auth_token(raw: str | None) -> tuple[str, str]:
value = str(raw or '').strip()
state = 'missing'
if not value:
return '', state
state = 'present'
for _ in range(4):
lowered = value.lower().strip()
if lowered.startswith('authorization:'):
value = value.split(':', 1)[1].strip()
state = 'authorization_prefix_stripped'
continue
if lowered.startswith('bearer '):
value = value[7:].strip()
state = 'bearer_prefix_stripped'
continue
break
if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")):
value = value[1:-1].strip()
return value, state
def authenticate(token: str):
if os.getenv('AIOS_GITOPS_DEV_ALLOW','0') == '1': return {'account':'dev-owner','role':'owner','scopes':['*']}
if not token: return None
token, _ = normalize_auth_token(token)
if not token or token.startswith('secret_ref://'):
return None
digest=hashlib.sha256(token.encode()).hexdigest()
for r in token_records():
if r.get('disabled'): continue
if r.get('token_sha256') and hmac.compare_digest(str(r['token_sha256']), digest): return r
if r.get('token_plain') and hmac.compare_digest(str(r['token_plain']), token): return r
plain = str(r.get('token_plain') or '')
norm_plain, _ = normalize_auth_token(plain)
if norm_plain and hmac.compare_digest(norm_plain, token): return r
return None
def sanitize(obj: Any) -> Any:
if isinstance(obj, dict):
@@ -135,6 +160,16 @@ def sync_tag(tag: str) -> dict:
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git tag push failed for {tag}')
return {'status':'synced','target':'origin','tag':tag,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
def commit_summary(c: Any) -> dict:
if not isinstance(c, dict):
return {'rev': None, 'sync_status': None, 'sync': None}
sync = c.get('sync') if isinstance(c.get('sync'), dict) else None
return {
'rev': c.get('rev'),
'sync_status': sync.get('status') if sync else None,
'sync': sync,
}
def commit(paths, msg):
ensure_git(); paths = [relpath(p) for p in paths] if paths else ['.']; sh(['add'] + paths, True)
branch=sh(['rev-parse','--abbrev-ref','HEAD'])['stdout']
@@ -159,20 +194,58 @@ class H(BaseHTTPRequestHandler):
b=json.dumps(data, ensure_ascii=False, indent=2).encode(); self.send_response(code); self.send_header('Content-Type','application/json; charset=utf-8'); self.send_header('Content-Length',str(len(b))); self.end_headers(); self.wfile.write(b)
def sendt(self, text, ctype='text/plain; charset=utf-8', code=200):
b=text.encode(); self.send_response(code); self.send_header('Content-Type',ctype); self.send_header('Content-Length',str(len(b))); self.end_headers(); self.wfile.write(b)
def send_head_only(self, code=200, ctype='application/json; charset=utf-8', length=0):
self.send_response(code); self.send_header('Content-Type',ctype); self.send_header('Content-Length',str(length)); self.end_headers()
def body(self):
n=int(self.headers.get('Content-Length','0') or '0'); raw=self.rfile.read(n).decode() if n else '{}'
try: return json.loads(raw or '{}')
except Exception: return {'_raw':raw}
def actor(self):
auth=self.headers.get('Authorization',''); tok=auth[7:].strip() if auth.startswith('Bearer ') else self.headers.get('X-AIOS-Token','')
auth=self.headers.get('Authorization','')
raw = auth if auth else self.headers.get('X-AIOS-Token','')
tok, auth_state = normalize_auth_token(raw)
if not raw:
auth_state = 'missing_bearer'
elif tok.startswith('secret_ref://'):
auth_state = 'secret_ref_literal_not_token'
rec=authenticate(tok)
if rec: return {'ok':True,'account':safe(rec.get('account') or rec.get('name') or 'token-user'),'role':rec.get('role','user'),'scopes':set(rec.get('scopes') or [])}
return {'ok':False,'account':'anonymous','role':'anonymous','scopes':set()}
if rec: return {'ok':True,'account':safe(rec.get('account') or rec.get('name') or 'token-user'),'role':rec.get('role','user'),'scopes':set(rec.get('scopes') or []),'auth_state':'authenticated'}
if raw and auth_state not in ('missing_bearer', 'secret_ref_literal_not_token'):
auth_state = 'invalid_token'
return {'ok':False,'account':'anonymous','role':'anonymous','scopes':set(),'auth_state':auth_state or 'invalid_token'}
def need(self, scope):
a=self.actor()
if not a['ok']: self.sendj({'error':'unauthorized','hint':'Bearer token required'},401); return None
if '*' not in a['scopes'] and scope not in a['scopes']: self.sendj({'error':'forbidden','required_scope':scope,'account':a['account']},403); return None
if not a['ok']:
hint = 'Bearer token required for GitOps write scope'
if a.get('auth_state') == 'invalid_token':
hint = 'Bearer token was received but does not match the GitOps write token registry'
elif a.get('auth_state') == 'secret_ref_literal_not_token':
hint = 'secret_ref is a pointer, not a credential; paste the raw runtime token into GPT Builder'
self.sendj({
'error':'unauthorized',
'hint':hint,
'auth_state':a.get('auth_state') or 'invalid_token',
'required_scope':scope,
'token_secret_ref':'secret_ref://aios-gitops/runtime/gitops-write-bot',
'auth_header':'Authorization: Bearer <raw-token>',
'rebind_required':True,
'gpt_builder_note':'When GPT Builder auth type is Bearer, paste only the raw token value. Do not paste secret_ref://... and do not include the word Bearer in the API key field.'
},401); return None
if '*' not in a['scopes'] and scope not in a['scopes']:
self.sendj({'error':'forbidden','required_scope':scope,'account':a['account'],'auth_state':a.get('auth_state'),'token_secret_ref':'secret_ref://aios-gitops/runtime/gitops-write-bot'},403); return None
return a
def do_HEAD(self):
u=urllib.parse.urlparse(self.path); path=u.path.rstrip('/') if u.path != '/' else u.path
try:
if path in ('/api/openapi.yaml','/api/openapi-chatgpt-actions.yaml','/api/schema'):
if not OPENAPI.exists():
return self.send_head_only(404)
return self.send_head_only(200, 'application/yaml; charset=utf-8', OPENAPI.stat().st_size)
if path in ('/health','/api/health','/api','/api/status','/api/base','/base','/api/ver','/ver','/api/diff','/diff','/api/patch','/patch','/api/model','/model','/api/run','/run'):
return self.send_head_only(200)
return self.send_head_only(404)
except Exception:
return self.send_head_only(500)
def do_GET(self):
u=urllib.parse.urlparse(self.path); path=u.path.rstrip('/') if u.path != '/' else u.path; q=urllib.parse.parse_qs(u.query)
try:
@@ -215,20 +288,20 @@ class H(BaseHTTPRequestHandler):
a=self.need('branch:create');
if not a: return
b=checkout(data.get('branch'), data.get('base','main'), True); meta={'type':'branch','branch':b,'base':data.get('base','main'),'created_by':a['account'],'created_at':now(),'direct_main_push':False,'owner_approval_required':True}
r=write_json(f'patches/{safe(b).replace("/","_")}.branch.json', meta); c=commit([r], data.get('message') or f'chore(chatgpt): create {b}'); audit(a['account'],'branch',b,meta); self.sendj({'status':'ok','branch':b,'commit':c})
r=write_json(f'patches/{safe(b).replace("/","_")}.branch.json', meta); c=commit([r], data.get('message') or f'chore(chatgpt): create {b}'); summary=commit_summary(c); audit(a['account'],'branch',b,meta); self.sendj({'status':'ok','branch':b,'commit':c, **summary})
def file(self,data):
a=self.need('file:write');
if not a: return
b=checkout(data.get('branch'), data.get('base','main'), bool(data.get('create_branch'))); r=write_file(data.get('path'), data.get('content','')); c=commit([r], data.get('message') or f'chore(chatgpt): update {r}'); audit(a['account'],'file',r,{'branch':b,'commit':c}); self.sendj({'status':'committed','branch':b,'path':r,'commit':c})
b=checkout(data.get('branch'), data.get('base','main'), bool(data.get('create_branch'))); r=write_file(data.get('path'), data.get('content','')); c=commit([r], data.get('message') or f'chore(chatgpt): update {r}'); summary=commit_summary(c); audit(a['account'],'file',r,{'branch':b,'commit':c}); self.sendj({'status':'committed','branch':b,'path':r,'commit':c, **summary})
def commit(self,data):
a=self.need('commit:create');
if not a: return
b=checkout(data.get('branch') or sh(['rev-parse','--abbrev-ref','HEAD'])['stdout'], data.get('base','main'), False); paths=data.get('paths') or ['.']; paths=[paths] if isinstance(paths,str) else paths; c=commit(paths, data.get('message') or 'chore(chatgpt): commit changes'); audit(a['account'],'commit',b,{'paths':paths,'commit':c}); self.sendj({'status':c['status'],'branch':b,'commit':c})
b=checkout(data.get('branch') or sh(['rev-parse','--abbrev-ref','HEAD'])['stdout'], data.get('base','main'), False); paths=data.get('paths') or ['.']; paths=[paths] if isinstance(paths,str) else paths; c=commit(paths, data.get('message') or 'chore(chatgpt): commit changes'); summary=commit_summary(c); audit(a['account'],'commit',b,{'paths':paths,'commit':c}); self.sendj({'status':c['status'],'branch':b,'commit':c, **summary})
def patch(self,data):
a=self.need('patch:create');
if not a: return
name=data.get('patch_name') or data.get('name') or uid('patch'); b=checkout(data.get('branch') or f'patch/{safe(name)}', data.get('base','main'), True); meta={'id':uid('patch'),'patch_name':name,'branch':b,'risk':data.get('risk','medium'),'description':data.get('description',''),'created_by':a['account'],'created_at':now()}
r=write_json(f'patches/{safe(name)}.json', meta); c=commit([r], data.get('message') or f'chore(chatgpt): create patch {name}'); audit(a['account'],'patch',name,meta); self.sendj({'status':'patch_branch_created','patch':meta,'commit':c})
r=write_json(f'patches/{safe(name)}.json', meta); c=commit([r], data.get('message') or f'chore(chatgpt): create patch {name}'); summary=commit_summary(c); audit(a['account'],'patch',name,meta); self.sendj({'status':'patch_branch_created','branch':b,'patch':meta,'commit':c, **summary})
def tag(self,data):
a=self.need('tag:create');
if not a: return
@@ -270,9 +343,23 @@ class H(BaseHTTPRequestHandler):
b=checkout(data.get('branch') or f'patch/nl-{uid("nl")}', 'main', True); doc={'id':uid('nl'),'text':text,'intent':intent,'action_plan':plan,'execute':False,'owner_approval_required':True,'created_by':a['account'],'created_at':now()}; r=write_json(f'patches/nl/{safe(doc["id"])}.json', doc); c=commit([r], f'chore(chatgpt): record nl plan {intent}'); self.sendj({'status':'planned','branch':b,'request':doc,'commit':c})
def api_spec():
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True}}
return {
'version':'V1R1C0-chatgpt-actions',
'openapi':'/api/openapi.yaml',
'read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run'],
'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl'],
'auth':{
'write_bearer_required':True,
'write_token_account':'gitops-write-bot',
'write_token_secret_ref':'secret_ref://aios-gitops/runtime/gitops-write-bot',
'required_header':'Authorization: Bearer <raw-token>',
'write_scopes':['branch:create','file:write','commit:create','patch:create','rollback:request','model:write','model:priority','nl:plan','runtime:test'],
'bridge_gpts':[{'name':'codex-chartgpt-bridge','gpt_id':'g-6a4ba1b4ea588191bab125d53ce83612','url':'https://chatgpt.com/g/g-6a4ba1b4ea588191bab125d53ce83612-codex-chartgpt-bridge','auth_binding_status':'requires_gpt_builder_bearer_secret_binding','api_key_field':'raw token only, without Bearer prefix'}]
},
'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True}
}
def main():
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
if __name__ == '__main__': main()
if __name__ == '__main__': main()
+51 -1
View File
@@ -46,7 +46,7 @@
"/api",
"/hook"
],
"updated_at": "2026-07-06T11:16:23Z",
"updated_at": "2026-07-06T13:24:28Z",
"auth": {
"primary_domain": "https://auth.cnnetmkt.top",
"backup_domain": "https://auth.h.imruyi.top:8443",
@@ -104,5 +104,55 @@
"portal_overlay": "overlays/user-control-center",
"portal_baseline_cache": "/app/runtime/gitops/current.json",
"sync_log_secret_hygiene": "runuser_without_sudo_command_logging"
},
"actions_write_auth": {
"account": "gitops-write-bot",
"token_ref": "secret_ref://aios-gitops/runtime/gitops-write-bot",
"header": "Authorization: Bearer <token>",
"scopes": [
"baseline:read",
"version:read",
"diff:read",
"patch:read",
"model:read",
"runtime:read",
"branch:create",
"file:write",
"commit:create",
"patch:create",
"rollback:request",
"model:write",
"model:priority",
"nl:plan",
"runtime:test"
],
"direct_main_push": false,
"delete_tag": false,
"owner_approval_required": true,
"updated_at": "2026-07-06T11:50:41Z",
"bridge_gpts": [
{
"name": "codex-chartgpt-bridge",
"gpt_id": "g-6a4ba1b4ea588191bab125d53ce83612",
"url": "https://chatgpt.com/g/g-6a4ba1b4ea588191bab125d53ce83612-codex-chartgpt-bridge",
"token_ref": "secret_ref://aios-gitops/runtime/gitops-write-bot",
"schema_url": "https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml",
"auth_binding_status": "requires_gpt_builder_bearer_secret_binding",
"server_side_smoke": "pass",
"updated_at": "2026-07-06T13:24:28Z"
}
],
"external_actions_binding_note": "ChatGPT GPT Builder must configure Bearer auth with the runtime token behind token_ref; server cannot inject the token into an external GPT by URL alone.",
"gpt_builder_api_key_field": "raw token only, without Bearer prefix",
"do_not_use_as_api_key": "secret_ref://aios-gitops/runtime/gitops-write-bot",
"last_client_response_error_fix": {
"updated_at": "2026-07-06T13:41:34Z",
"server_changes": [
"normalize duplicated Bearer/Authorization prefixes",
"return auth_state diagnostics",
"flatten branch/rev/sync_status on write success"
],
"external_builder_requirement": "paste raw token into Bearer API key field"
}
}
}
@@ -0,0 +1,59 @@
{
"name": "codex-chartgpt-bridge",
"gpt_id": "g-6a4ba1b4ea588191bab125d53ce83612",
"gpt_url": "https://chatgpt.com/g/g-6a4ba1b4ea588191bab125d53ce83612-codex-chartgpt-bridge",
"api_base": "https://cnnetmkt.top/api",
"openapi_schema": "https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml",
"auth": {
"type": "bearer",
"header": "Authorization: Bearer <token>",
"token_account": "gitops-write-bot",
"token_ref": "secret_ref://aios-gitops/runtime/gitops-write-bot",
"runtime_token_file": "/data/aios-gitops/runtime/secrets/gitops-write-bot.token",
"plaintext_token_in_git": false
},
"scopes": {
"read": [
"baseline:read",
"version:read",
"diff:read",
"patch:read",
"model:read",
"runtime:read"
],
"write": [
"branch:create",
"file:write",
"commit:create",
"patch:create",
"rollback:request",
"model:write",
"model:priority",
"nl:plan",
"runtime:test"
]
},
"policy": {
"direct_main_push": false,
"delete_tag": false,
"owner_approval_required": true,
"v_level_owner_admin_approval_required": true,
"api_keys_in_git": false
},
"status": {
"server_side_smoke": "pass",
"external_gpt_builder_auth_binding": "required_in_gpt_builder_ui",
"last_updated_at": "2026-07-06T13:24:28Z"
},
"gpt_builder_api_key_field": "raw token only, without Bearer prefix",
"do_not_use_as_api_key": "secret_ref://aios-gitops/runtime/gitops-write-bot",
"last_client_response_error_fix": {
"updated_at": "2026-07-06T13:41:34Z",
"server_changes": [
"normalize duplicated Bearer/Authorization prefixes",
"return auth_state diagnostics",
"flatten branch/rev/sync_status on write success"
],
"external_builder_requirement": "paste raw token into Bearer API key field"
}
}
+51 -1
View File
@@ -53,7 +53,7 @@
],
"created_at": "2026-07-06T10:21:08Z",
"base_commit_before_release": "43ddb91",
"updated_at": "2026-07-06T11:16:23Z",
"updated_at": "2026-07-06T13:24:28Z",
"last_collaboration_test": {
"timestamp": "20260706T104130Z",
"result": "local_codex_and_custom_gpt_patches_merged"
@@ -74,5 +74,55 @@
"portal_overlay": "overlays/user-control-center",
"portal_baseline_cache": "/app/runtime/gitops/current.json",
"sync_log_secret_hygiene": "runuser_without_sudo_command_logging"
},
"actions_write_auth": {
"account": "gitops-write-bot",
"token_ref": "secret_ref://aios-gitops/runtime/gitops-write-bot",
"header": "Authorization: Bearer <token>",
"scopes": [
"baseline:read",
"version:read",
"diff:read",
"patch:read",
"model:read",
"runtime:read",
"branch:create",
"file:write",
"commit:create",
"patch:create",
"rollback:request",
"model:write",
"model:priority",
"nl:plan",
"runtime:test"
],
"direct_main_push": false,
"delete_tag": false,
"owner_approval_required": true,
"updated_at": "2026-07-06T11:50:41Z",
"bridge_gpts": [
{
"name": "codex-chartgpt-bridge",
"gpt_id": "g-6a4ba1b4ea588191bab125d53ce83612",
"url": "https://chatgpt.com/g/g-6a4ba1b4ea588191bab125d53ce83612-codex-chartgpt-bridge",
"token_ref": "secret_ref://aios-gitops/runtime/gitops-write-bot",
"schema_url": "https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml",
"auth_binding_status": "requires_gpt_builder_bearer_secret_binding",
"server_side_smoke": "pass",
"updated_at": "2026-07-06T13:24:28Z"
}
],
"external_actions_binding_note": "ChatGPT GPT Builder must configure Bearer auth with the runtime token behind token_ref; server cannot inject the token into an external GPT by URL alone.",
"gpt_builder_api_key_field": "raw token only, without Bearer prefix",
"do_not_use_as_api_key": "secret_ref://aios-gitops/runtime/gitops-write-bot",
"last_client_response_error_fix": {
"updated_at": "2026-07-06T13:41:34Z",
"server_changes": [
"normalize duplicated Bearer/Authorization prefixes",
"return auth_state diagnostics",
"flatten branch/rev/sync_status on write success"
],
"external_builder_requirement": "paste raw token into Bearer API key field"
}
}
}
@@ -0,0 +1,81 @@
# Codex ChatGPT Bridge Actions Setup
Date: 2026-07-06T13:24:28Z
GPT: codex-chartgpt-bridge
URL: https://chatgpt.com/g/g-6a4ba1b4ea588191bab125d53ce83612-codex-chartgpt-bridge
## Current Server-Side State
The AIOS GitOps API write path is ready for this GPT bridge.
- API base: `https://cnnetmkt.top/api`
- OpenAPI schema: `https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml`
- Auth type: Bearer token
- Required header: `Authorization: Bearer <token>`
- Token account: `gitops-write-bot`
- Token source: `secret_ref://aios-gitops/runtime/gitops-write-bot`
- Runtime token file: `/data/aios-gitops/runtime/secrets/gitops-write-bot.token`
The token value must not be copied into Git, docs, logs, prompts, or issue comments.
## Required GPT Builder Action Binding
The GPT URL alone cannot make the GPT send a Bearer header. In the GPT Builder Actions configuration for `codex-chartgpt-bridge`, bind authentication as:
```text
Authentication type: API Key / Bearer
Auth header: Authorization
Auth value / API key field: <raw runtime token value only>
Schema URL: https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml
Secret source: secret_ref://aios-gitops/runtime/gitops-write-bot
```
If the builder asks for the token value, read it only from the runtime secret file on the server and paste it into the GPT Builder secret field. Do not paste it into chat.
## Server-Side Smoke Already Passed
- no-token `createPatch`: `401 unauthorized`
- `gitops-write-bot` `createPatch`: `200`, branch synced
- `gitops-write-bot` `commitFile`: `200`, branch synced
- `codex-gitops-bot` `createPatch`: `200`, branch synced
- `codex-gitops-bot` `commitFile`: `200`, branch synced
## Latest ClientResponseError Fix
Current public API and auth smoke status:
- `GET /api/base`, `GET /api/run`, `GET /api/status`, and the OpenAPI schema URL return `200`.
- no-token writes return `401` with `auth_state=missing_bearer`.
- using the public `secret_ref://...` string as the API key returns `401` with `auth_state=secret_ref_literal_not_token`.
- duplicated `Bearer ` prefixes are normalized server-side and accepted when the underlying token is valid.
- valid raw-token `createPatch` and `commitFile` return `branch`, `rev`, and `sync_status` at top level.
- `HEAD /api/base`, `HEAD /api/run`, and `HEAD /api/openapi-chatgpt-actions.yaml` return `200`, preventing endpoint validators from treating public reads as unsupported.
The GPT Builder API key field must contain only the raw token value.
If Actions returns `auth_state=invalid_token`, the GPT did send a Bearer value, but it is not the current runtime token. Re-open the GPT Builder Action authentication dialog, replace the hidden API key field with the current raw value from `/data/aios-gitops/runtime/secrets/gitops-write-bot.token`, save the Action, publish/update the GPT, and start a fresh GPT conversation before retesting.
## External GPT Retest
After binding the Bearer token in GPT Builder, ask the GPT to run:
1. `createPatch` with a branch under `patch/`.
2. `commitFile` on that same branch.
3. `getDiff` for the new branch versus `main`.
Expected result:
- `createPatch` returns `200`.
- `commitFile` returns `200`.
- branch sync status is `synced`.
- no direct `main` write occurs.
## Security Boundary
- `direct_main_push=false`
- `delete_tag=false`
- `owner_approval_required=true`
- V-level operations still require owner/admin approval.
- Custom LLM API keys must use secret refs only.
Important: because the GPT Builder screenshot has Bearer selected, the API key field must contain only the raw token value. Do not type `Bearer ` in that field. Do not type `secret_ref://aios-gitops/runtime/gitops-write-bot`; that string is a public reference, not a credential.
+57
View File
@@ -0,0 +1,57 @@
# GitOps Write Auth Fix
Date: 2026-07-06T11:50:41Z
Branch: `patch/gitops-write-auth-fix`
## Root Cause
Read operations were working because they are public at the server layer. Write operations returned `401 unauthorized` when ChatGPT Actions did not inject `Authorization: Bearer ...` for write calls.
Runtime token records already supported write scopes for bot accounts, so the API write path itself was not broken. A bearer-authenticated smoke test proved `createPatch` and `commitFile` succeed when the write token is supplied.
## Fix
- Added/normalized runtime bot account: `gitops-write-bot`.
- Runtime token file: `/data/aios-gitops/runtime/secrets/gitops-write-bot.token`.
- Git-visible credential reference only: `secret_ref://aios-gitops/runtime/gitops-write-bot`.
- Added token hash and scopes to `/data/aios-gitops/runtime/secrets/gitops.tokens`.
- Updated OpenAPI schema to declare bearer auth at top level and on write operations.
- Updated API 401/403 responses to include required scope and `token_secret_ref`, without exposing secrets.
- Added smoke test: `scripts/smoke_gitops_write_auth.py`.
## Required Actions Auth Binding
Configure ChatGPT Actions / Codex Actions auth with:
- Auth type: Bearer token
- Header: `Authorization: Bearer <token>`
- Secret source: `secret_ref://aios-gitops/runtime/gitops-write-bot`
Do not paste the token into Git, docs, logs, prompts, or issue comments.
## Smoke
```bash
python3 scripts/smoke_gitops_write_auth.py --api https://cnnetmkt.top/api --token-file /data/aios-gitops/runtime/secrets/gitops-write-bot.token
```
Expected:
- no-token `createPatch` returns `401`
- authenticated `createPatch` returns `200`
- authenticated `commitFile` returns `200`
## Rollback
1. Remove or disable the `gitops-write-bot` JSON record in `/data/aios-gitops/runtime/secrets/gitops.tokens`.
2. Remove `/data/aios-gitops/runtime/secrets/gitops-write-bot.token` if rotation is required.
3. Revert this patch branch or restore OpenAPI/API files from `main`.
4. Restart the API service:
```bash
sudo systemctl restart aios-gitops-model-api.service
```
5. Verify unauthenticated writes still return `401`.
Important: because the GPT Builder screenshot has Bearer selected, the API key field must contain only the raw token value. Do not type `Bearer ` in that field. Do not type `secret_ref://aios-gitops/runtime/gitops-write-bot`; that string is a public reference, not a credential.
@@ -0,0 +1,135 @@
# GitOps Write Auth Fix Acceptance
Time: 2026-07-06T13:26:26Z
Branch: `patch/gitops-write-auth-fix`
Patch commit: see `git rev-parse --short patch/gitops-write-auth-fix`
Bridge GPT: `codex-chartgpt-bridge`
Bridge URL: https://chatgpt.com/g/g-6a4ba1b4ea588191bab125d53ce83612-codex-chartgpt-bridge
## Result
SERVER-SIDE PASS. The GitOps API write path accepts valid Bearer tokens and rejects no-token writes. The external GPT still must have Bearer authentication configured in GPT Builder Actions; a GPT URL alone cannot inject the header.
## Root Cause Confirmed
- Read endpoints were healthy without auth.
- Write endpoints returned `401 unauthorized` when Actions did not send `Authorization: Bearer ...`.
- Runtime token registry supports write scopes; the broken boundary was Actions auth header injection / write token binding.
## Fix Applied
- Added dedicated runtime account `gitops-write-bot`.
- Runtime token file: `/data/aios-gitops/runtime/secrets/gitops-write-bot.token`, mode `600`.
- Token registry stores hash/scopes only in `/data/aios-gitops/runtime/secrets/gitops.tokens`.
- Git-visible token reference only: `secret_ref://aios-gitops/runtime/gitops-write-bot`.
- OpenAPI now advertises Bearer auth, write operation scopes, and the bridge GPT URL.
- `/api/status` returns `bridge_gpts` with the `codex-chartgpt-bridge` URL and auth-binding status.
- Added bridge setup doc: `docs/gitops/CODEX_CHATGPT_BRIDGE_ACTIONS_SETUP.md`.
- Added smoke script: `scripts/smoke_gitops_write_auth.py`.
## Smoke Results
### gitops-write-bot
```json
{
"noauth": {"operation": "createPatch without token", "http": 401, "status": "unauthorized"},
"createPatch": {"operation": "createPatch", "http": 200, "status": "patch_branch_created", "branch": "patch/gitops-write-auth-smoke-20260706T115230Z", "rev": "6d3a050", "sync_status": "synced"},
"commitFile": {"operation": "commitFile", "http": 200, "status": "committed", "branch": "patch/gitops-write-auth-smoke-20260706T115230Z", "rev": "17344f7", "sync_status": "synced"}
}
```
### codex-gitops-bot
```json
{
"noauth": {"operation": "createPatch without token", "http": 401, "status": "unauthorized"},
"createPatch": {"operation": "createPatch", "http": 200, "status": "patch_branch_created", "branch": "patch/codex-write-auth-smoke-20260706T115256Z", "rev": "d684df2", "sync_status": "synced"},
"commitFile": {"operation": "commitFile", "http": 200, "status": "committed", "branch": "patch/codex-write-auth-smoke-20260706T115256Z", "rev": "884a5ec", "sync_status": "synced"}
}
```
### codex-chartgpt-bridge binding smoke
```json
{
"noauth": {"operation": "createPatch without token", "http": 401, "status": "unauthorized"},
"createPatch": {"operation": "createPatch", "http": 200, "status": "patch_branch_created", "branch": "patch/bridge-write-auth-smoke-20260706T132506Z", "rev": "f4ef8e5", "sync_status": "synced"},
"commitFile": {"operation": "commitFile", "http": 200, "status": "committed", "branch": "patch/bridge-write-auth-smoke-20260706T132506Z", "rev": "0ed0816", "sync_status": "synced"}
}
```
## ClientResponseError Retest (2026-07-06T13:47:06Z)
The external GPT Actions failure was reproduced as a write-auth boundary issue, not a public endpoint outage. Public reads reached the API and returned typed responses, while writes without a valid Bearer credential returned structured `401` responses.
```text
GET /api/base: 200 application/json
GET /api/run: 200 application/json
GET /api/status: 200 application/json
GET /api/openapi-chatgpt-actions.yaml: 200 application/yaml
POST /api/patch without token: 401 auth_state=missing_bearer
POST /api/patch with literal secret_ref as token: 401 auth_state=secret_ref_literal_not_token
POST /api/patch with duplicated Bearer prefix: 200 branch=patch/actions-double-bearer-20260706T134646Z rev=ddd8711 sync_status=synced
POST /api/patch with raw runtime token: 200 branch=patch/actions-raw-token-smoke-20260706T134706Z rev=0e48e1d sync_status=synced
POST /api/file with raw runtime token: 200 branch=patch/actions-raw-token-smoke-20260706T134706Z rev=6ab67e9 sync_status=synced
```
Server-side compatibility added in this patch:
- Normalize accidental `Authorization:` and repeated `Bearer ` prefixes before token verification.
- Reject literal `secret_ref://...` as a token and return `auth_state=secret_ref_literal_not_token`.
- Return `auth_state`, `required_scope`, and GPT Builder guidance on `401`.
- Return top-level `branch`, `rev`, and `sync_status` on write success so Actions can make deterministic pass/fail decisions.
- Support `HEAD` probes for public read/schema endpoints so GPT Builder or gateway validators do not see `501` during endpoint discovery.
GPT Builder still must store the raw runtime token in the hidden API key field. The public `secret_ref://aios-gitops/runtime/gitops-write-bot` value is a pointer only and must not be used as the API key.
HEAD compatibility retest:
```text
HEAD /api/base: 200 application/json
HEAD /api/run: 200 application/json
HEAD /api/openapi-chatgpt-actions.yaml: 200 application/yaml
```
## External GPT Retest (2026-07-06T13:59:43Z)
The custom GPT retest reached the server:
```text
GET /api/run: 200
GET /api/base: 200
POST /api/branch: 401
POST /api/patch: 401
```
This confirms that routing, TLS, schema loading, and read access are healthy. The remaining failure is the external GPT Builder secret binding: the write request does not carry the runtime `gitops-write-bot` raw token value. If a token-like value is present but does not match the token registry, the API now reports `auth_state=invalid_token` and `rebind_required=true`.
## External GPT Builder Binding Required
Configure the GPT Actions auth for `codex-chartgpt-bridge` as:
```text
Schema URL: https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml
Authentication type: API Key / Bearer
Header: Authorization
Value / API key field: <raw runtime token value only>
Secret source: secret_ref://aios-gitops/runtime/gitops-write-bot
```
The actual token value must be read from `/data/aios-gitops/runtime/secrets/gitops-write-bot.token` and entered only into the GPT Builder secret/auth field. Do not paste it into chat or Git.
## Exit Criteria
- ChatGPT Actions write capability: PASS once the GPT Builder auth field is bound to the runtime token.
- Server-side authenticated write path: PASS.
- Codex API commit capability: PASS.
- No-token write requests remain rejected: PASS, `401 unauthorized`.
- No plaintext token committed to Git: PASS by workspace secret pattern scan.
## Security Notes
The patch does not allow direct `main` writes, does not permit tag deletion, and does not bypass owner/admin approval for V-level actions. Credentials stay in runtime secrets; Git stores only secret refs and token hashes.
Important: because the GPT Builder screenshot has Bearer selected, the API key field must contain only the raw token value. Do not type `Bearer ` in that field. Do not type `secret_ref://aios-gitops/runtime/gitops-write-bot`; that string is a public reference, not a credential.
+49 -4
View File
@@ -3,15 +3,23 @@ info:
title: AIOS GitOps Controlled Access API
version: V1R1C0-chatgpt-actions
description: Controlled ChatGPT Actions API for AIOS online GitOps baseline. Read endpoints are public. Write endpoints require Bearer token and create Git commits on non-main branches.
x-aios-bridge-gpts:
- name: codex-chartgpt-bridge
url: https://chatgpt.com/g/g-6a4ba1b4ea588191bab125d53ce83612-codex-chartgpt-bridge
token_secret_ref: secret_ref://aios-gitops/runtime/gitops-write-bot
api_key_field: raw token only, without Bearer prefix
servers:
- url: https://cnnetmkt.top
security: []
security:
- bearerAuth: []
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: opaque-token
description: Use Bearer auth for write operations. In GPT Builder Bearer mode, paste only the raw runtime token into the API key field; do not include the word Bearer and do not paste the public secret_ref string. Read endpoints remain public at server level.
x-token-secret-ref: secret_ref://aios-gitops/runtime/gitops-write-bot
schemas:
BranchRequest:
type: object
@@ -97,6 +105,26 @@ components:
priority: {type: integer}
enabled: {type: boolean}
fallback_policy: {type: string}
ActionWriteResult:
type: object
properties:
status: {type: string}
branch: {type: string}
rev: {type: string}
sync_status: {type: string}
commit: {type: object}
patch: {type: object}
path: {type: string}
ErrorResponse:
type: object
properties:
error: {type: string}
hint: {type: string}
auth_state: {type: string}
required_scope: {type: string}
token_secret_ref: {type: string}
auth_header: {type: string}
gpt_builder_note: {type: string}
NLRequest:
type: object
required: [text]
@@ -133,6 +161,7 @@ paths:
responses: {'200': {description: OK}}
post:
operationId: createPatch
x-required-scope: patch:create
summary: Create a patch branch and commit metadata
security: [{bearerAuth: []}]
requestBody:
@@ -140,7 +169,10 @@ paths:
content:
application/json:
schema: {$ref: '#/components/schemas/PatchRequest'}
responses: {'200': {description: Patch branch committed}, '401': {description: Unauthorized}, '403': {description: Forbidden}}
responses:
'200': {description: Patch branch committed, content: {application/json: {schema: {$ref: '#/components/schemas/ActionWriteResult'}}}}
'401': {description: Unauthorized, content: {application/json: {schema: {$ref: '#/components/schemas/ErrorResponse'}}}}
'403': {description: Forbidden, content: {application/json: {schema: {$ref: '#/components/schemas/ErrorResponse'}}}}
/api/model:
get:
operationId: readModelRegistry
@@ -148,6 +180,7 @@ paths:
responses: {'200': {description: OK}}
post:
operationId: registerModel
x-required-scope: model:write
summary: Register a custom model; API keys are stored as runtime secrets and Git receives secret_ref only
security: [{bearerAuth: []}]
requestBody:
@@ -164,6 +197,7 @@ paths:
/api/branch:
post:
operationId: createBranch
x-required-scope: branch:create
summary: Create a non-main branch and commit branch metadata
security: [{bearerAuth: []}]
requestBody:
@@ -171,10 +205,14 @@ paths:
content:
application/json:
schema: {$ref: '#/components/schemas/BranchRequest'}
responses: {'200': {description: Branch created}, '401': {description: Unauthorized}, '403': {description: Forbidden}}
responses:
'200': {description: Branch created, content: {application/json: {schema: {$ref: '#/components/schemas/ActionWriteResult'}}}}
'401': {description: Unauthorized, content: {application/json: {schema: {$ref: '#/components/schemas/ErrorResponse'}}}}
'403': {description: Forbidden, content: {application/json: {schema: {$ref: '#/components/schemas/ErrorResponse'}}}}
/api/file:
post:
operationId: commitFile
x-required-scope: file:write
summary: Write a file and create a Git commit on a non-main branch
security: [{bearerAuth: []}]
requestBody:
@@ -182,10 +220,14 @@ paths:
content:
application/json:
schema: {$ref: '#/components/schemas/FileCommitRequest'}
responses: {'200': {description: File committed}, '400': {description: Secret rejected or invalid path}, '401': {description: Unauthorized}}
responses:
'200': {description: File committed, content: {application/json: {schema: {$ref: '#/components/schemas/ActionWriteResult'}}}}
'400': {description: Secret rejected or invalid path, content: {application/json: {schema: {$ref: '#/components/schemas/ErrorResponse'}}}}
'401': {description: Unauthorized, content: {application/json: {schema: {$ref: '#/components/schemas/ErrorResponse'}}}}
/api/commit:
post:
operationId: commitChanges
x-required-scope: commit:create
summary: Commit existing workspace changes on a non-main branch
security: [{bearerAuth: []}]
requestBody:
@@ -208,6 +250,7 @@ paths:
/api/rollback:
post:
operationId: requestRollback
x-required-scope: rollback:request
summary: Commit a rollback request requiring owner/admin approval
security: [{bearerAuth: []}]
requestBody:
@@ -219,6 +262,7 @@ paths:
/api/model-priority:
put:
operationId: setModelPriority
x-required-scope: model:priority
summary: Set model priority and commit policy file
security: [{bearerAuth: []}]
requestBody:
@@ -230,6 +274,7 @@ paths:
/api/nl:
post:
operationId: planNaturalLanguageGitOpsAction
x-required-scope: nl:plan
summary: Convert natural language to a safe committed plan without executing V-level changes
security: [{bearerAuth: []}]
requestBody:
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse, json, os, ssl, time, urllib.request
from pathlib import Path
def post_json(api: str, path: str, token: str | None, payload: dict) -> tuple[int, dict]:
data = json.dumps(payload, ensure_ascii=False).encode('utf-8')
req = urllib.request.Request(api.rstrip('/') + path, data=data, method='POST')
req.add_header('Content-Type', 'application/json')
if token:
req.add_header('Authorization', f'Bearer {token}')
ctx = ssl._create_unverified_context()
try:
with urllib.request.urlopen(req, timeout=30, context=ctx) as r:
return r.status, json.loads(r.read().decode('utf-8'))
except urllib.error.HTTPError as e:
body = e.read().decode('utf-8', errors='ignore')
try:
obj = json.loads(body)
except Exception:
obj = {'raw': body[:300]}
return e.code, obj
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument('--api', default=os.environ.get('AIOS_GITOPS_API', 'https://cnnetmkt.top/api'))
ap.add_argument('--token-file', default=os.environ.get('AIOS_GITOPS_WRITE_TOKEN_FILE', '/data/aios-gitops/runtime/secrets/gitops-write-bot.token'))
ap.add_argument('--token-secret-ref', default=os.environ.get('AIOS_GITOPS_WRITE_TOKEN_REF', 'secret_ref://aios-gitops/runtime/gitops-write-bot'))
ap.add_argument('--branch-prefix', default='patch/gitops-write-auth-smoke')
args = ap.parse_args()
token = Path(args.token_file).read_text(encoding='utf-8').strip()
stamp = time.strftime('%Y%m%dT%H%M%SZ', time.gmtime())
branch = f'{args.branch_prefix}-{stamp}'
patch_name = f'gitops-write-auth-smoke-{stamp}'
noauth_code, noauth_body = post_json(args.api, '/patch', None, {'patch_name': patch_name + '-noauth'})
if noauth_code != 401:
raise SystemExit(f'expected noauth 401, got {noauth_code}: {noauth_body}')
patch_code, patch_body = post_json(args.api, '/patch', token, {
'patch_name': patch_name,
'branch': branch,
'base': 'main',
'risk': 'low',
'description': 'GitOps write auth smoke using secret_ref token',
'message': f'test(gitops): create write auth smoke patch {stamp}',
})
if patch_code != 200 or patch_body.get('status') != 'patch_branch_created':
raise SystemExit(f'createPatch failed: {patch_code}: {patch_body}')
file_code, file_body = post_json(args.api, '/file', token, {
'branch': branch,
'base': 'main',
'create_branch': False,
'path': f'docs/gitops/write-auth-smoke-{stamp}.md',
'content': f'# GitOps Write Auth Smoke\n\nTime: {stamp}\nResult: createPatch and commitFile authenticated via secret_ref token.\n',
'message': f'test(gitops): commit write auth smoke {stamp}',
})
if file_code != 200 or file_body.get('status') != 'committed':
raise SystemExit(f'commitFile failed: {file_code}: {file_body}')
def summarize(name: str, code: int, body: dict) -> dict:
commit = body.get('commit') or {}
return {
'operation': name,
'http': code,
'status': body.get('status'),
'branch': body.get('branch') or (body.get('patch') or {}).get('branch'),
'rev': commit.get('rev') if isinstance(commit, dict) else None,
'sync_status': ((commit.get('sync') or {}).get('status') if isinstance(commit, dict) else None),
}
print(json.dumps({
'noauth': {'operation': 'createPatch without token', 'http': noauth_code, 'status': noauth_body.get('error')},
'createPatch': summarize('createPatch', patch_code, patch_body),
'commitFile': summarize('commitFile', file_code, file_body),
'token_secret_ref': args.token_secret_ref,
}, ensure_ascii=False, indent=2))
return 0
if __name__ == '__main__':
raise SystemExit(main())