coopagents 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. coopagents/__init__.py +1 -0
  2. coopagents/account_api.py +213 -0
  3. coopagents/account_recovery.py +84 -0
  4. coopagents/accounts.py +178 -0
  5. coopagents/activity.py +102 -0
  6. coopagents/api.py +383 -0
  7. coopagents/backup.py +55 -0
  8. coopagents/billing.py +583 -0
  9. coopagents/billing_admin.py +152 -0
  10. coopagents/billing_api.py +168 -0
  11. coopagents/billing_checkout.py +82 -0
  12. coopagents/billing_config.py +64 -0
  13. coopagents/billing_operations.py +50 -0
  14. coopagents/billing_service.py +223 -0
  15. coopagents/billing_worker.py +40 -0
  16. coopagents/checkpoint_hook.py +236 -0
  17. coopagents/checkpoints.py +189 -0
  18. coopagents/config.py +95 -0
  19. coopagents/contracts.py +110 -0
  20. coopagents/coordination.py +117 -0
  21. coopagents/coordination_http.py +119 -0
  22. coopagents/database.py +232 -0
  23. coopagents/dependencies.py +275 -0
  24. coopagents/errors.py +21 -0
  25. coopagents/events.py +97 -0
  26. coopagents/git_adapter.py +253 -0
  27. coopagents/git_routes.py +108 -0
  28. coopagents/git_workspace.py +224 -0
  29. coopagents/github_app.py +342 -0
  30. coopagents/github_sync.py +471 -0
  31. coopagents/identity.py +274 -0
  32. coopagents/invite_message.py +143 -0
  33. coopagents/mcp_service.py +127 -0
  34. coopagents/messages.py +203 -0
  35. coopagents/oauth.py +136 -0
  36. coopagents/p2p.py +317 -0
  37. coopagents/protocol.py +30 -0
  38. coopagents/realtime.py +121 -0
  39. coopagents/repositories.py +222 -0
  40. coopagents/server.py +48 -0
  41. coopagents/skills/coopagents-connect/SKILL.md +42 -0
  42. coopagents/skills/coopagents-connect/protocol.md +55 -0
  43. coopagents/skills/coopagents-space/SKILL.md +61 -0
  44. coopagents/skills/coopagents-space/protocol.md +70 -0
  45. coopagents/skills/coopagents-space/scripts/verify_cloudflared.py +59 -0
  46. coopagents/space_board.py +118 -0
  47. coopagents/space_bridge.py +299 -0
  48. coopagents/space_connect.py +183 -0
  49. coopagents/space_runtime.py +440 -0
  50. coopagents/space_server.py +95 -0
  51. coopagents/space_wake.py +580 -0
  52. coopagents/spaces.py +141 -0
  53. coopagents/static/accounts.js +213 -0
  54. coopagents/static/app.js +296 -0
  55. coopagents/static/billing.js +162 -0
  56. coopagents/static/board.html +8 -0
  57. coopagents/static/board.js +58 -0
  58. coopagents/static/collaboration.js +73 -0
  59. coopagents/static/dependencies.js +143 -0
  60. coopagents/static/git.js +125 -0
  61. coopagents/static/icon.png +0 -0
  62. coopagents/static/index.html +626 -0
  63. coopagents/static/invitations.js +70 -0
  64. coopagents/static/realtime_client.js +36 -0
  65. coopagents/static/space_board.js +67 -0
  66. coopagents/static/styles.css +2093 -0
  67. coopagents/static/task_lifecycle.js +146 -0
  68. coopagents/stripe_billing.py +347 -0
  69. coopagents/stripe_snapshot.py +166 -0
  70. coopagents/task_lifecycle.py +301 -0
  71. coopagents/tasks.py +86 -0
  72. coopagents/tunnels.py +61 -0
  73. coopagents/wake_digest.py +42 -0
  74. coopagents/workspaces.py +259 -0
  75. coopagents-0.7.0.dist-info/METADATA +200 -0
  76. coopagents-0.7.0.dist-info/RECORD +79 -0
  77. coopagents-0.7.0.dist-info/WHEEL +5 -0
  78. coopagents-0.7.0.dist-info/licenses/LICENSE +21 -0
  79. coopagents-0.7.0.dist-info/top_level.txt +1 -0
coopagents/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Local Coopagents collaboration primitives."""
@@ -0,0 +1,213 @@
1
+ """Cookie authenticated account HTTP routes. Domain stores own authorization."""
2
+ import threading
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import time
7
+ import unicodedata
8
+ from starlette.concurrency import run_in_threadpool
9
+ from starlette.responses import JSONResponse
10
+ from starlette.routing import Route
11
+ from .errors import AccessDenied, ValidationError
12
+
13
+ COOKIE = 'coopagents_account'
14
+ THROTTLE_LIMIT = 10000
15
+
16
+ class AccountBusy(Exception):
17
+ pass
18
+
19
+
20
+ def cookie_token(request, name=COOKIE, required=True):
21
+ values = []
22
+ for header in request.headers.getlist('cookie'):
23
+ for part in header.split(';'):
24
+ key, sep, value = part.strip().partition('=')
25
+ if key == name and sep:
26
+ values.append(value)
27
+ if len(values) > 1 or (required and len(values) != 1) or any(not 1 <= len(value) <= 128 for value in values):
28
+ raise AccessDenied('Account cookie not accepted')
29
+ return values[0] if values else None
30
+
31
+
32
+ def session_response(info, secure):
33
+ token = info['session_token']
34
+ response = JSONResponse({key: value for key, value in info.items() if key != 'session_token'})
35
+ response.set_cookie(COOKIE, token, max_age=604800, httponly=True, secure=secure, samesite='lax', path='/')
36
+ return response
37
+
38
+
39
+ class LoginThrottle:
40
+ """Persist short windows, without permanent account lockout or global lockout."""
41
+ def __init__(self, db):
42
+ self.db = db
43
+ with db.connect(write=True) as c:
44
+ c.execute('CREATE TABLE IF NOT EXISTS account_login_throttle(key TEXT PRIMARY KEY,window REAL NOT NULL,attempts INTEGER NOT NULL)')
45
+
46
+ def attempt(self, username, address):
47
+ now = time.time()
48
+ username = unicodedata.normalize('NFKC', username).strip().casefold()
49
+ keys = [(hashlib.sha256(('user:' + username).encode()).hexdigest(), 20),
50
+ (hashlib.sha256(('ip:' + address).encode()).hexdigest(), 60)]
51
+ with self.db.connect(write=True) as c:
52
+ for key, limit in keys:
53
+ row = c.execute('SELECT window,attempts FROM account_login_throttle WHERE key=?', (key,)).fetchone()
54
+ start, count = (row['window'], row['attempts']) if row and now - row['window'] < 60 else (now, 0)
55
+ if count >= limit:
56
+ raise AccessDenied('Login temporarily unavailable; try again later')
57
+ if row is None and c.execute('SELECT count(*) FROM account_login_throttle').fetchone()[0] >= THROTTLE_LIMIT:
58
+ expired = c.execute('SELECT key FROM account_login_throttle WHERE window<=? ORDER BY window LIMIT 1', (now - 60,)).fetchone()
59
+ if expired is None:
60
+ raise AccessDenied('Login temporarily unavailable; try again later')
61
+ c.execute('UPDATE account_login_throttle SET key=?,window=?,attempts=0 WHERE key=?', (key, now, expired['key']))
62
+ c.execute('INSERT INTO account_login_throttle VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET window=excluded.window,attempts=excluded.attempts', (key, start, count + 1))
63
+
64
+
65
+ def account_routes(service, *, mode, origin, bootstrap_secret, oauth):
66
+ from .api import error_payload, validate, fields
67
+ accounts, workspaces = service.accounts, service.workspaces
68
+ expensive = threading.BoundedSemaphore(2)
69
+ throttle = LoginThrottle(accounts.db)
70
+ service.login_throttle = throttle
71
+ secure = bool(origin and origin.startswith('https://'))
72
+ bootstrap_hash = hashlib.sha256(bootstrap_secret.encode()).digest() if bootstrap_secret else None
73
+
74
+ def schema(required='', optional=None):
75
+ return fields(required, optional)
76
+
77
+ operations = {
78
+ '/account/teams': (workspaces.create_team, schema('name')),
79
+ '/account/password': (accounts.change_password, schema('current_password new_password')),
80
+ '/account/logout': (accounts.logout, schema()),
81
+ '/account/teams/{team_id}/projects': (workspaces.create_project, schema('name', {'repository': ''})),
82
+ '/account/teams/{team_id}/claim': (workspaces.claim_legacy_project, schema('owner_web_token')),
83
+ '/account/teams/{team_id}/invites': (workspaces.create_invite, {**schema('', {'role': 'member'}), 'project_ids': (list, False, [])}),
84
+ '/account/teams/{team_id}/members/{account_id}/role': (workspaces.change_role, schema('role')),
85
+ '/account/teams/{team_id}/members/{account_id}/remove': (workspaces.remove_member, schema()),
86
+ '/account/teams/{team_id}/members/{account_id}/reset-password': (workspaces.reset_password, schema('new_password')),
87
+ '/account/projects/{room_id}/select': (workspaces.project_credential, schema()),
88
+ '/account/projects/{room_id}/members/{account_id}/grant': (workspaces.grant_project, schema()),
89
+ '/account/projects/{room_id}/members/{account_id}/remove': (workspaces.remove_project_member, schema()),
90
+ }
91
+ reads = {
92
+ '/account/session': accounts.session_info,
93
+ '/account/teams': workspaces.list_teams,
94
+ '/account/teams/{team_id}/projects': workspaces.list_projects,
95
+ '/account/teams/{team_id}/managed-projects': workspaces.list_managed_projects,
96
+ '/account/teams/{team_id}/members': workspaces.list_members,
97
+ }
98
+
99
+ async def execute(method, *args, **kwargs):
100
+ # Bounds password work as well as database work outside the event loop.
101
+ if not expensive.acquire(blocking=False):
102
+ raise AccountBusy()
103
+ try:
104
+ return await run_in_threadpool(method, *args, **kwargs)
105
+ finally:
106
+ expensive.release()
107
+
108
+ async def handle(request):
109
+ try:
110
+ path = request.url.path
111
+ expected_origin = origin or str(request.base_url).rstrip('/')
112
+ if request.method == 'POST':
113
+ if request.headers.getlist('origin') != [expected_origin]:
114
+ return JSONResponse({'error': 'AccessDenied', 'message': 'Origin not accepted'}, status_code=403)
115
+ token = cookie_token(request, required=False)
116
+ if path == '/account/config':
117
+ return JSONResponse({'mode': mode, 'local_login_enabled': mode == 'self-hosted',
118
+ 'bootstrap_enabled': mode == 'self-hosted' and bootstrap_hash is not None and await execute(accounts.local_setup_available),
119
+ 'github_oauth_enabled': oauth is not None})
120
+ if request.method == 'GET':
121
+ if not token:
122
+ raise AccessDenied('Account cookie required')
123
+ try:
124
+ result = await execute(reads[request.scope['account_pattern']], token, **request.path_params)
125
+ except AccessDenied:
126
+ if path != '/account/session':
127
+ raise
128
+ response = JSONResponse({'error': 'AccessDenied', 'message': 'Account session not accepted'}, status_code=401)
129
+ response.delete_cookie(COOKIE, httponly=True, secure=secure, samesite='lax', path='/')
130
+ return response
131
+ return JSONResponse(result)
132
+ if mode == 'cloud' and (path in ('/account/login', '/account/bootstrap', '/account/password') or path.endswith('/reset-password')):
133
+ raise AccessDenied('Local account authentication disabled')
134
+ if request.headers.get('content-type', '').split(';')[0].strip().lower() != 'application/json':
135
+ raise ValidationError('Content type must be application/json')
136
+ try:
137
+ data = json.loads(await request.body())
138
+ except (ValueError, UnicodeError):
139
+ raise ValidationError('Malformed JSON') from None
140
+ if path == '/account/logout':
141
+ validate(data, schema())
142
+ try:
143
+ if not token:
144
+ raise AccessDenied('Account session not accepted')
145
+ await execute(accounts.authenticate, token)
146
+ except AccessDenied:
147
+ response = JSONResponse({'ok': True})
148
+ response.delete_cookie(COOKIE, httponly=True, secure=secure, samesite='lax', path='/')
149
+ return response
150
+ if path == '/account/invites/preview':
151
+ data = validate(data, schema('invite_token'))
152
+ return JSONResponse(await execute(workspaces.preview_invite, **data), headers={'Cache-Control': 'no-store'})
153
+ if token and path not in ('/account/login', '/account/bootstrap'):
154
+ csrf = request.headers.getlist('x-coopagents-csrf')
155
+ if len(csrf) != 1:
156
+ raise AccessDenied('CSRF credential required')
157
+ await execute(accounts.authenticate, token, csrf[0])
158
+ if path == '/account/bootstrap':
159
+ if bootstrap_hash is None or len(request.headers.getlist('x-coopagents-bootstrap')) != 1 or not hmac.compare_digest(hashlib.sha256(request.headers['x-coopagents-bootstrap'].encode()).digest(), bootstrap_hash):
160
+ raise AccessDenied('Bootstrap credential not accepted')
161
+ data = validate(data, schema('username password', {'display_name': None}))
162
+ account = await execute(accounts.bootstrap, **data)
163
+ return session_response(await execute(accounts.create_session, account['id']), secure)
164
+ if path == '/account/login':
165
+ data = validate(data, schema('username password'))
166
+ await execute(throttle.attempt, data['username'], request.client.host if request.client else '')
167
+ return session_response(await execute(accounts.login, **data), secure)
168
+ if path == '/account/invites/accept':
169
+ if token:
170
+ data = validate(data, schema('invite_token'))
171
+ return JSONResponse(await execute(workspaces.accept_invite, session_token=token, **data))
172
+ if mode == 'cloud':
173
+ raise AccessDenied('GitHub account session required')
174
+ data = validate(data, schema('invite_token username password', {'display_name': None}))
175
+ await execute(throttle.attempt, data['username'], request.client.host if request.client else '')
176
+ result = await execute(workspaces.accept_invite, **data)
177
+ info = await execute(accounts.create_session, result['account']['id'])
178
+ return session_response({**result, **info}, secure)
179
+ if not token:
180
+ raise AccessDenied('Account cookie required')
181
+ method, body_schema = operations[request.scope['account_pattern']]
182
+ data = validate(data, body_schema)
183
+ if 'project_ids' in data:
184
+ project_ids = data['project_ids']
185
+ if len(project_ids) > 200 or any(type(value) is not str or not value.strip() or len(value) > 120 for value in project_ids):
186
+ raise ValidationError('Project IDs must contain at most 200 nonempty strings of at most 120 characters')
187
+ try:
188
+ for value in project_ids:
189
+ value.encode('utf-8')
190
+ except UnicodeEncodeError:
191
+ raise ValidationError('Project IDs must contain valid Unicode') from None
192
+ result = await execute(method, token, **request.path_params, **data)
193
+ response = JSONResponse(result if result is not None else {'ok': True})
194
+ if path in ('/account/logout', '/account/password'):
195
+ response.delete_cookie(COOKIE, httponly=True, secure=secure, samesite='lax', path='/')
196
+ return response
197
+ except AccountBusy:
198
+ return JSONResponse({'error': 'Busy', 'message': 'Service busy; try again later'}, status_code=503)
199
+ except Exception as error:
200
+ payload, status = error_payload(error)
201
+ return JSONResponse(payload, status_code=status)
202
+
203
+ result = []
204
+ patterns = set(operations) | set(reads) | {'/account/config', '/account/bootstrap', '/account/login', '/account/invites/accept', '/account/invites/preview'}
205
+ for pattern in sorted(patterns):
206
+ async def endpoint(request, pattern=pattern):
207
+ request.scope['account_pattern'] = pattern
208
+ return await handle(request)
209
+ methods = (['GET'] if pattern in reads or pattern == '/account/config' else []) + (['POST'] if pattern in operations or pattern in ('/account/bootstrap', '/account/login', '/account/invites/accept', '/account/invites/preview') else [])
210
+ result.append(Route(pattern, endpoint, methods=methods))
211
+ if oauth:
212
+ result.extend(oauth.routes(service, execute, secure))
213
+ return result
@@ -0,0 +1,84 @@
1
+ """Offline local-password recovery; possession of the database is authority."""
2
+ import argparse
3
+ from contextlib import contextmanager
4
+ import getpass
5
+ from pathlib import Path
6
+ import sqlite3
7
+ import sys
8
+ from types import SimpleNamespace
9
+ import warnings
10
+
11
+ from .accounts import AccountStore, _username
12
+ from .errors import ValidationError
13
+
14
+
15
+ class ExistingDatabase:
16
+ """Open only an existing file, including if the file disappears after validation."""
17
+ def __init__(self, path):
18
+ self.uri = Path(path).resolve().as_uri() + '?mode=rw'
19
+
20
+ @contextmanager
21
+ def connect(self, write=False):
22
+ c = sqlite3.connect(self.uri, uri=True, timeout=10, isolation_level=None)
23
+ try:
24
+ c.row_factory = sqlite3.Row
25
+ c.execute('PRAGMA foreign_keys=ON')
26
+ if write:
27
+ c.execute('BEGIN IMMEDIATE')
28
+ try:
29
+ yield c
30
+ if write:
31
+ c.commit()
32
+ except BaseException:
33
+ if write:
34
+ c.rollback()
35
+ raise
36
+ finally:
37
+ c.close()
38
+
39
+
40
+ def recover_password(database, username, new_password):
41
+ username = _username(username)
42
+ db = ExistingDatabase(database)
43
+ try:
44
+ # Deliberately do not run schema creation/migrations during recovery.
45
+ # Use the domain reset on the existing schema in one writer transaction.
46
+ with db.connect(write=True) as c:
47
+ row = c.execute('SELECT id,password_hash FROM accounts WHERE username=?', (username,)).fetchone()
48
+ if row is None or row['password_hash'] is None:
49
+ raise ValidationError('Existing local password account required')
50
+ store = AccountStore.__new__(AccountStore)
51
+ store.db = db
52
+ store.identity = SimpleNamespace(db=db)
53
+ store._reset_password(c, row['id'], new_password)
54
+ except sqlite3.Error:
55
+ raise ValidationError('Existing account database could not be opened or updated') from None
56
+
57
+
58
+ def main(argv=None):
59
+ parser = argparse.ArgumentParser(description=__doc__)
60
+ parser.add_argument('--database', required=True, help='Existing Coopagents SQLite file')
61
+ parser.add_argument('--username', required=True, help='Existing local username (not GitHub display name)')
62
+ args = parser.parse_args(argv)
63
+ try:
64
+ # getpass otherwise falls back to echoed stdin on terminals it cannot
65
+ # control. Abort before that fallback can read a plaintext password.
66
+ with warnings.catch_warnings():
67
+ warnings.simplefilter('error', getpass.GetPassWarning)
68
+ password = getpass.getpass('New password (12–256 characters): ')
69
+ confirmation = getpass.getpass('Repeat new password: ')
70
+ if password != confirmation:
71
+ raise ValidationError('Password confirmation does not match')
72
+ recover_password(args.database, args.username, password)
73
+ except getpass.GetPassWarning:
74
+ print('Recovery requires a terminal with hidden password input', file=sys.stderr)
75
+ return 1
76
+ except (ValidationError, OSError, EOFError, KeyboardInterrupt) as error:
77
+ print(str(error) or 'Recovery cancelled', file=sys.stderr)
78
+ return 1
79
+ print('Password reset. All account sessions and their project web access are invalidated.')
80
+ return 0
81
+
82
+
83
+ if __name__ == '__main__':
84
+ raise SystemExit(main())
coopagents/accounts.py ADDED
@@ -0,0 +1,178 @@
1
+ """Local accounts and revocable browser sessions, independent of room tokens."""
2
+ import hashlib
3
+ import hmac
4
+ import secrets
5
+ import sqlite3
6
+ import unicodedata
7
+ from datetime import timedelta
8
+ from uuid import uuid4
9
+
10
+ from .errors import AccessDenied, ValidationError
11
+ from .identity import _name, _token_hash, _utcnow, _expired
12
+
13
+
14
+ def _username(value):
15
+ if not isinstance(value, str):
16
+ raise ValidationError('Username required')
17
+ value = unicodedata.normalize('NFKC', value).strip().casefold()
18
+ try:
19
+ value.encode('utf-8')
20
+ except UnicodeEncodeError:
21
+ raise ValidationError('Username must be valid Unicode') from None
22
+ if not 1 <= len(value) <= 120 or any(ord(ch) < 32 for ch in value):
23
+ raise ValidationError('Username must contain 1 to 120 characters')
24
+ return value
25
+
26
+
27
+ def _password(value):
28
+ if not isinstance(value, str) or not 12 <= len(value) <= 256:
29
+ raise ValidationError('Password must contain 12 to 256 characters')
30
+ try:
31
+ return value.encode('utf-8')
32
+ except UnicodeEncodeError:
33
+ raise ValidationError('Password must be valid Unicode') from None
34
+
35
+
36
+ def _hash_password(password, salt):
37
+ return hashlib.scrypt(_password(password), salt=bytes.fromhex(salt), n=16384, r=8, p=1).hex()
38
+
39
+
40
+ class AccountStore:
41
+ def __init__(self, identity):
42
+ self.identity, self.db = identity, identity.db
43
+ with self.db.connect(write=True) as c:
44
+ c.execute('''CREATE TABLE IF NOT EXISTS accounts (
45
+ id TEXT PRIMARY KEY, username TEXT UNIQUE, display_name TEXT NOT NULL,
46
+ password_salt TEXT, password_hash TEXT, created_at TEXT NOT NULL)''')
47
+ c.execute('''CREATE TABLE IF NOT EXISTS account_sessions (
48
+ token_hash TEXT PRIMARY KEY, account_id TEXT NOT NULL REFERENCES accounts(id),
49
+ expires_at TEXT NOT NULL, revoked_at TEXT)''')
50
+ c.execute('''CREATE TABLE IF NOT EXISTS oauth_accounts (
51
+ provider TEXT NOT NULL, provider_id TEXT NOT NULL,
52
+ account_id TEXT NOT NULL REFERENCES accounts(id), PRIMARY KEY(provider,provider_id))''')
53
+
54
+ def local_setup_available(self):
55
+ with self.db.connect() as c:
56
+ return c.execute('SELECT 1 FROM accounts LIMIT 1').fetchone() is None
57
+
58
+ def _create_local(self, c, username, password, display_name=None):
59
+ username = _username(username)
60
+ salt = secrets.token_hex(16)
61
+ password_hash = _hash_password(password, salt)
62
+ account_id = str(uuid4())
63
+ try:
64
+ c.execute('INSERT INTO accounts VALUES(?,?,?,?,?,?)',
65
+ (account_id, username, _name(display_name or username), salt, password_hash, _utcnow().isoformat()))
66
+ except sqlite3.IntegrityError:
67
+ raise ValidationError('Username unavailable') from None
68
+ return self._get(c, account_id)
69
+
70
+ def bootstrap(self, username, password, display_name=None):
71
+ with self.db.connect(write=True) as c:
72
+ if c.execute('SELECT 1 FROM accounts LIMIT 1').fetchone():
73
+ raise AccessDenied('Initial account already exists')
74
+ return self._create_local(c, username, password, display_name)
75
+
76
+ bootstrap_account = bootstrap
77
+
78
+ def _get(self, c, account_id):
79
+ row = c.execute('SELECT id,username,display_name FROM accounts WHERE id=?', (account_id,)).fetchone()
80
+ if row is None:
81
+ raise AccessDenied('Account not accepted')
82
+ return dict(row)
83
+
84
+ def get_account(self, account_id):
85
+ with self.db.connect() as c:
86
+ return self._get(c, account_id)
87
+
88
+ @staticmethod
89
+ def csrf_token(session_token):
90
+ _token_hash(session_token)
91
+ return hashlib.sha256(('coopagents-csrf:' + session_token).encode()).hexdigest()
92
+
93
+ def _create_session(self, c, account_id):
94
+ account = self._get(c, account_id)
95
+ token = secrets.token_urlsafe(32)
96
+ expiry = (_utcnow() + timedelta(days=7)).isoformat()
97
+ c.execute('INSERT INTO account_sessions VALUES(?,?,?,NULL)', (_token_hash(token), account_id, expiry))
98
+ return {'session_token': token, 'csrf_token': self.csrf_token(token), 'expires_at': expiry, 'account': account}
99
+
100
+ def create_session(self, account_id):
101
+ """Trusted OAuth adapter hook; never expose arbitrary IDs as HTTP login."""
102
+ with self.db.connect(write=True) as c:
103
+ return self._create_session(c, account_id)
104
+
105
+ def login(self, username, password):
106
+ try:
107
+ username = _username(username)
108
+ _password(password)
109
+ except ValidationError:
110
+ raise AccessDenied('Login not accepted') from None
111
+ with self.db.connect() as c:
112
+ row = c.execute('SELECT * FROM accounts WHERE username=?', (username,)).fetchone()
113
+ # Password work stays outside the SQLite writer lock. Recheck the verified
114
+ # hash inside the transaction so a concurrent reset cannot grant a session.
115
+ salt = row['password_salt'] if row and row['password_salt'] else '00' * 16
116
+ candidate = _hash_password(password, salt)
117
+ if row is None or not row['password_hash'] or not hmac.compare_digest(candidate, row['password_hash']):
118
+ raise AccessDenied('Login not accepted')
119
+ with self.db.connect(write=True) as c:
120
+ current = c.execute('SELECT password_hash FROM accounts WHERE id=?', (row['id'],)).fetchone()
121
+ if current is None or current['password_hash'] != row['password_hash']:
122
+ raise AccessDenied('Login not accepted')
123
+ return self._create_session(c, row['id'])
124
+
125
+ def resolve(self, c, session_token, csrf_token=None):
126
+ row = c.execute('SELECT account_id,expires_at FROM account_sessions WHERE token_hash=? AND revoked_at IS NULL',
127
+ (_token_hash(session_token),)).fetchone()
128
+ if row is None or _expired(row['expires_at'], _utcnow()):
129
+ raise AccessDenied('Account session not accepted')
130
+ if csrf_token is not None and (not isinstance(csrf_token, str) or not hmac.compare_digest(csrf_token, self.csrf_token(session_token))):
131
+ raise AccessDenied('CSRF credential not accepted')
132
+ return self._get(c, row['account_id'])
133
+
134
+ def authenticate(self, session_token, csrf_token=None):
135
+ with self.db.connect() as c:
136
+ return self.resolve(c, session_token, csrf_token)
137
+
138
+ def session_info(self, session_token):
139
+ with self.db.connect() as c:
140
+ account = self.resolve(c, session_token)
141
+ row = c.execute('SELECT expires_at FROM account_sessions WHERE token_hash=?', (_token_hash(session_token),)).fetchone()
142
+ return {'account': account, 'csrf_token': self.csrf_token(session_token), 'expires_at': row['expires_at']}
143
+
144
+ def logout(self, session_token):
145
+ with self.db.connect(write=True) as c:
146
+ c.execute('UPDATE account_sessions SET revoked_at=? WHERE token_hash=?', (_utcnow().isoformat(), _token_hash(session_token)))
147
+
148
+ def _reset_password(self, c, account_id, new_password):
149
+ row = c.execute('SELECT password_hash FROM accounts WHERE id=?', (account_id,)).fetchone()
150
+ if row is None or row['password_hash'] is None:
151
+ raise ValidationError('Local password account required')
152
+ salt = secrets.token_hex(16)
153
+ c.execute('UPDATE accounts SET password_salt=?,password_hash=? WHERE id=?', (salt, _hash_password(new_password, salt), account_id))
154
+ c.execute('UPDATE account_sessions SET revoked_at=? WHERE account_id=? AND revoked_at IS NULL', (_utcnow().isoformat(), account_id))
155
+
156
+ def change_password(self, session_token, current_password, new_password):
157
+ with self.db.connect(write=True) as c:
158
+ account = self.resolve(c, session_token)
159
+ row = c.execute('SELECT password_salt,password_hash FROM accounts WHERE id=?', (account['id'],)).fetchone()
160
+ try:
161
+ valid = row['password_hash'] and hmac.compare_digest(_hash_password(current_password, row['password_salt']), row['password_hash'])
162
+ except ValidationError:
163
+ valid = False
164
+ if not valid:
165
+ raise AccessDenied('Current password not accepted')
166
+ self._reset_password(c, account['id'], new_password)
167
+
168
+ def oauth_account(self, provider, provider_id, display_name):
169
+ if provider != 'github' or not isinstance(provider_id, str) or not provider_id.isascii() or not provider_id.isdigit():
170
+ raise ValidationError('GitHub numeric provider ID required')
171
+ with self.db.connect(write=True) as c:
172
+ row = c.execute('SELECT account_id FROM oauth_accounts WHERE provider=? AND provider_id=?', (provider, provider_id)).fetchone()
173
+ if row:
174
+ return self._get(c, row['account_id'])
175
+ account_id = str(uuid4())
176
+ c.execute('INSERT INTO accounts VALUES(?,NULL,?,NULL,NULL,?)', (account_id, _name(display_name), _utcnow().isoformat()))
177
+ c.execute('INSERT INTO oauth_accounts VALUES(?,?,?)', (provider, provider_id, account_id))
178
+ return self._get(c, account_id)
coopagents/activity.py ADDED
@@ -0,0 +1,102 @@
1
+ """Explicit agent session heartbeats and self-reported task progress."""
2
+ from itertools import combinations
3
+
4
+ from .errors import AccessDenied, NotFound, ValidationError
5
+ from .tasks import _text, _utc_now
6
+
7
+
8
+ TOOLS = ("claude-code", "codex-cli", "codex-desktop")
9
+ PROGRESS_STATES = ("working", "blocked", "local_done", "pushed", "pr_open", "merged")
10
+
11
+
12
+ def _task_id(value):
13
+ if type(value) is not int or value <= 0:
14
+ raise ValidationError("Task id must be a positive integer")
15
+ return value
16
+
17
+
18
+ class ActivityStore:
19
+ def __init__(self, identity_store):
20
+ self.db = identity_store.db
21
+ self.resolve = identity_store.resolve
22
+
23
+ def _owned_task(self, connection, principal, task_id, session_key=None, ownership_epoch=None):
24
+ from .task_lifecycle import execution_task
25
+ return execution_task(connection, principal, task_id, session_key, ownership_epoch)
26
+
27
+ def register_session(self, agent_token, session_key, tool, repository, branch, task_id=None, ownership_epoch=None):
28
+ session_key = _text(session_key, "Session key", 200)
29
+ repository = _text(repository, "Repository", 500)
30
+ branch = _text(branch, "Branch", 200)
31
+ if tool not in TOOLS:
32
+ raise ValidationError("Tool must be claude-code, codex-cli or codex-desktop")
33
+ if task_id is not None:
34
+ task_id = _task_id(task_id)
35
+ with self.db.connect(write=True) as connection:
36
+ principal = self.resolve(connection, agent_token, required_kind="agent")
37
+ if task_id is not None:
38
+ self._owned_task(connection, principal, task_id, session_key, ownership_epoch)
39
+ connection.execute("""INSERT INTO sessions
40
+ (room_id, member_id, session_key, tool, repository, branch, task_id, updated_at)
41
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
42
+ ON CONFLICT (room_id, member_id, session_key) DO UPDATE SET
43
+ tool = excluded.tool, repository = excluded.repository,
44
+ branch = excluded.branch, task_id = excluded.task_id,
45
+ updated_at = excluded.updated_at""",
46
+ (principal.room_id, principal.member_id, session_key, tool,
47
+ repository, branch, task_id, _utc_now()))
48
+ return dict(connection.execute("""SELECT * FROM sessions
49
+ WHERE room_id = ? AND member_id = ? AND session_key = ?""",
50
+ (principal.room_id, principal.member_id, session_key)).fetchone())
51
+
52
+ def list_sessions(self, token):
53
+ with self.db.connect() as connection:
54
+ principal = self.resolve(connection, token)
55
+ return [dict(row) for row in connection.execute(
56
+ "SELECT * FROM sessions WHERE room_id = ? ORDER BY id", (principal.room_id,))]
57
+
58
+ def report_progress(self, agent_token, task_id, state, summary, evidence="", session_key=None, ownership_epoch=None):
59
+ task_id = _task_id(task_id)
60
+ if state not in PROGRESS_STATES:
61
+ raise ValidationError("Unsupported progress state")
62
+ summary = _text(summary, "Summary", 4000)
63
+ if not isinstance(evidence, str) or len(evidence.strip()) > 1000:
64
+ raise ValidationError("Evidence must contain at most 1000 characters")
65
+ evidence = evidence.strip()
66
+ with self.db.connect(write=True) as connection:
67
+ principal = self.resolve(connection, agent_token, required_kind="agent")
68
+ self._owned_task(connection, principal, task_id, session_key, ownership_epoch)
69
+ connection.execute("""INSERT INTO progress
70
+ (task_id, room_id, member_id, state, summary, evidence, source, updated_at)
71
+ VALUES (?, ?, ?, ?, ?, ?, 'agent_report', ?)
72
+ ON CONFLICT (task_id) DO UPDATE SET member_id = excluded.member_id, state = excluded.state,
73
+ summary = excluded.summary, evidence = excluded.evidence,
74
+ updated_at = excluded.updated_at""",
75
+ (task_id, principal.room_id, principal.member_id, state, summary, evidence, _utc_now()))
76
+ return dict(connection.execute("SELECT * FROM progress WHERE task_id = ?",
77
+ (task_id,)).fetchone())
78
+
79
+ def list_progress(self, token):
80
+ with self.db.connect() as connection:
81
+ principal = self.resolve(connection, token)
82
+ return [dict(row) for row in connection.execute(
83
+ "SELECT * FROM progress WHERE room_id = ? ORDER BY task_id", (principal.room_id,))]
84
+
85
+ def overlaps(self, token):
86
+ """Compare declared scope strings; this performs no filesystem operations."""
87
+ with self.db.connect() as connection:
88
+ principal = self.resolve(connection, token)
89
+ tasks = connection.execute("SELECT id, scope FROM tasks WHERE room_id = ? AND cancelled_at IS NULL ORDER BY id",
90
+ (principal.room_id,)).fetchall()
91
+ result = []
92
+ for first, second in combinations(tasks, 2):
93
+ left, right = first["scope"].rstrip("/"), second["scope"].rstrip("/")
94
+ reason = None
95
+ if left == right:
96
+ reason = "Declared scopes are equal"
97
+ elif left and right and (left.startswith(right + "/") or right.startswith(left + "/")):
98
+ reason = "One declared scope is a slash-segment prefix of the other"
99
+ if reason:
100
+ result.append({"task_ids": [first["id"], second["id"]],
101
+ "scopes": [first["scope"], second["scope"]], "reason": reason})
102
+ return result