codex-passport-sync 0.1.1__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.
- codex_passport/__init__.py +2 -0
- codex_passport/__main__.py +2 -0
- codex_passport/ble.py +127 -0
- codex_passport/cli.py +270 -0
- codex_passport/relay.py +133 -0
- codex_passport/routing.py +46 -0
- codex_passport/sessions.py +155 -0
- codex_passport/store.py +198 -0
- codex_passport/usage.py +87 -0
- codex_passport_sync-0.1.1.dist-info/METADATA +105 -0
- codex_passport_sync-0.1.1.dist-info/RECORD +14 -0
- codex_passport_sync-0.1.1.dist-info/WHEEL +4 -0
- codex_passport_sync-0.1.1.dist-info/entry_points.txt +2 -0
- codex_passport_sync-0.1.1.dist-info/licenses/LICENSE +21 -0
codex_passport/ble.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Cross-platform BLE delivery; paired GATT writes and explicit device receipts."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from .cli import snapshot, device_frame
|
|
8
|
+
|
|
9
|
+
SERVICE = 'fac06f64-6578-2d70-6173-73706f727401'
|
|
10
|
+
RX = 'fac06f64-6578-2d70-6173-73706f727402'
|
|
11
|
+
TX = 'fac06f64-6578-2d70-6173-73706f727403'
|
|
12
|
+
LOG = logging.getLogger('codex-passport')
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Receipts:
|
|
16
|
+
def __init__(self, store):
|
|
17
|
+
self.store = store
|
|
18
|
+
self.buffer = bytearray()
|
|
19
|
+
self.pending = None
|
|
20
|
+
self.ack = asyncio.Event()
|
|
21
|
+
|
|
22
|
+
def receive(self, _, data):
|
|
23
|
+
self.buffer.extend(data)
|
|
24
|
+
while b'\n' in self.buffer:
|
|
25
|
+
line, _, rest = self.buffer.partition(b'\n')
|
|
26
|
+
self.buffer = bytearray(rest)
|
|
27
|
+
try:
|
|
28
|
+
reply = json.loads(line)
|
|
29
|
+
except ValueError:
|
|
30
|
+
continue
|
|
31
|
+
if not isinstance(reply, dict) or reply.get('v') != 1:
|
|
32
|
+
continue
|
|
33
|
+
if self.pending and reply.get('ack') == self.pending[0]:
|
|
34
|
+
event = self.pending[1]
|
|
35
|
+
if event and reply.get('event') == event['id']:
|
|
36
|
+
self.store.delivered(event['id'])
|
|
37
|
+
elif event:
|
|
38
|
+
continue
|
|
39
|
+
self.ack.set()
|
|
40
|
+
read = reply.get('read')
|
|
41
|
+
if isinstance(read, int) and not isinstance(read, bool) and read >= 0:
|
|
42
|
+
self.store.mark_read(min(read, self.store.summary()['latest']))
|
|
43
|
+
if len(self.buffer) > 4096:
|
|
44
|
+
self.buffer.clear()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def deliver(client, receipts, store, seq):
|
|
48
|
+
event = store.pending()
|
|
49
|
+
frame = device_frame(snapshot(store, seq, event))
|
|
50
|
+
raw = json.dumps(frame, ensure_ascii=False, separators=(',', ':')).encode() + b'\n'
|
|
51
|
+
if len(raw) > 2048:
|
|
52
|
+
raise ValueError('Snapshot exceeds the device frame limit')
|
|
53
|
+
receipts.pending = (seq, event)
|
|
54
|
+
receipts.ack.clear()
|
|
55
|
+
size = max(20, min(180, client.mtu_size - 3))
|
|
56
|
+
security_deadline = time.monotonic() + 110
|
|
57
|
+
for offset in range(0, len(raw), size):
|
|
58
|
+
while True:
|
|
59
|
+
try:
|
|
60
|
+
await client.write_gatt_char(RX, raw[offset:offset+size], response=True)
|
|
61
|
+
break
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
# CoreBluetooth can return an ATT security error while its PIN dialog
|
|
64
|
+
# is still open. Keep this connection alive; never downgrade security.
|
|
65
|
+
security_error = any(s in str(exc).lower() for s in
|
|
66
|
+
('insufficient encryption', 'insufficient authentication'))
|
|
67
|
+
if not security_error or not client.is_connected or time.monotonic() >= security_deadline:
|
|
68
|
+
raise
|
|
69
|
+
await asyncio.sleep(1)
|
|
70
|
+
await asyncio.wait_for(receipts.ack.wait(), timeout=15)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def connect_and_sync(device, store, routing, generation, once=False):
|
|
74
|
+
from bleak import BleakClient, BleakScanner
|
|
75
|
+
# Select explicitly; never pair with an arbitrary nearby Passport.
|
|
76
|
+
found = await BleakScanner.find_device_by_filter(
|
|
77
|
+
lambda d, ad: d.address.lower() == device.lower() or ad.local_name == device,
|
|
78
|
+
timeout=30, service_uuids=[SERVICE])
|
|
79
|
+
if found is None:
|
|
80
|
+
raise TimeoutError('Selected Passport is not advertising nearby')
|
|
81
|
+
if routing.view()['generation'] != generation:
|
|
82
|
+
return False
|
|
83
|
+
async with BleakClient(found, pair=True, timeout=45) as client:
|
|
84
|
+
receipts = Receipts(store)
|
|
85
|
+
await client.start_notify(TX, receipts.receive)
|
|
86
|
+
seq = int(time.time()*1000) % 2_000_000_000
|
|
87
|
+
last_send, signature = 0, ''
|
|
88
|
+
while client.is_connected:
|
|
89
|
+
route = routing.view()
|
|
90
|
+
if route['owner'] != 'desktop' or route['generation'] != generation:
|
|
91
|
+
return False
|
|
92
|
+
frame = device_frame(snapshot(store, 0, store.pending()))
|
|
93
|
+
frame.pop('now')
|
|
94
|
+
current = json.dumps(frame, sort_keys=True)
|
|
95
|
+
if current != signature or time.monotonic() - last_send >= 10:
|
|
96
|
+
seq = seq % 2_000_000_000 + 1
|
|
97
|
+
if last_send:
|
|
98
|
+
await asyncio.wait_for(deliver(client, receipts, store, seq), timeout=35)
|
|
99
|
+
else:
|
|
100
|
+
await deliver(client, receipts, store, seq)
|
|
101
|
+
routing.acknowledged(generation)
|
|
102
|
+
(LOG.info if not last_send else LOG.debug)('Desktop BLE device ACK confirmed (sequence %d)', seq)
|
|
103
|
+
last_send, signature = time.monotonic(), current
|
|
104
|
+
if once:
|
|
105
|
+
return True
|
|
106
|
+
await asyncio.sleep(1)
|
|
107
|
+
raise ConnectionError('Passport disconnected')
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def worker(device, store, routing, once=False):
|
|
111
|
+
while True:
|
|
112
|
+
route = routing.view()
|
|
113
|
+
if route['owner'] == 'desktop':
|
|
114
|
+
generation = route['generation']
|
|
115
|
+
try:
|
|
116
|
+
operation = connect_and_sync(device, store, routing, generation, once)
|
|
117
|
+
acknowledged = await asyncio.wait_for(operation, 170) if once else await operation
|
|
118
|
+
if once:
|
|
119
|
+
if not acknowledged:
|
|
120
|
+
raise TimeoutError('Handoff expired before a device acknowledgement')
|
|
121
|
+
return
|
|
122
|
+
except Exception as exc:
|
|
123
|
+
LOG.warning('Desktop BLE unavailable (%s); returning ownership to phone', type(exc).__name__)
|
|
124
|
+
routing.failed(generation)
|
|
125
|
+
if once:
|
|
126
|
+
raise
|
|
127
|
+
await asyncio.sleep(1)
|
codex_passport/cli.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""CLI, lifecycle-hook installation and a reconnecting USB worker."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import shlex
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from .store import Store, LABELS, clipped
|
|
13
|
+
from .sessions import Sessions
|
|
14
|
+
from .usage import UsageClient
|
|
15
|
+
|
|
16
|
+
LOG = logging.getLogger('codex-passport')
|
|
17
|
+
HOOK_EVENTS = ['SessionStart', 'SessionEnd', 'UserPromptSubmit', 'Stop', 'Interrupt',
|
|
18
|
+
'SubagentStart', 'SubagentStop', 'PermissionRequest', 'PreCompact', 'PostCompact',
|
|
19
|
+
'PreToolUse', 'PostToolUse']
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def state_dir():
|
|
23
|
+
return Path(os.environ.get('CODEX_PASSPORT_STATE', str(Path.home()/'.local/state/codex-passport'))).expanduser()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def hook_config(command):
|
|
27
|
+
events = {}
|
|
28
|
+
for event in HOOK_EVENTS:
|
|
29
|
+
handler = {'type': 'command', 'command': command, 'timeout': 3}
|
|
30
|
+
# Small local SQLite write. Synchronous ordering avoids late Stop beating a new turn.
|
|
31
|
+
group = {'hooks': [handler]}
|
|
32
|
+
if event == 'PreToolUse':
|
|
33
|
+
group['matcher'] = '.*(request_user_input|requestUserInput).*'
|
|
34
|
+
events[event] = [group]
|
|
35
|
+
return {'hooks': events}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def install_hooks(args):
|
|
39
|
+
target = args.codex_home / 'hooks.json'
|
|
40
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
old = json.loads(target.read_text()) if target.exists() else {'hooks': {}}
|
|
42
|
+
if not isinstance(old, dict) or not isinstance(old.get('hooks', {}), dict):
|
|
43
|
+
raise ValueError('Existing hooks.json has an unsupported structure')
|
|
44
|
+
command = shlex.join([sys.executable, '-m', 'codex_passport', '--state-dir', str(args.state_dir.resolve()), 'hook'])
|
|
45
|
+
merged = json.loads(json.dumps(old))
|
|
46
|
+
hooks = merged.setdefault('hooks', {})
|
|
47
|
+
# Idempotent: replace only entries installed by this application.
|
|
48
|
+
for event, groups in hook_config(command)['hooks'].items():
|
|
49
|
+
previous = hooks.setdefault(event, [])
|
|
50
|
+
cleaned = []
|
|
51
|
+
for group in previous:
|
|
52
|
+
group = dict(group)
|
|
53
|
+
group['hooks'] = [h for h in group.get('hooks', []) if '-m codex_passport ' not in h.get('command', '')]
|
|
54
|
+
if group['hooks']:
|
|
55
|
+
cleaned.append(group)
|
|
56
|
+
hooks[event] = cleaned + groups
|
|
57
|
+
if target.exists():
|
|
58
|
+
backup = target.with_name('hooks.json.passport-backup-' + str(time.time_ns()))
|
|
59
|
+
backup.write_bytes(target.read_bytes())
|
|
60
|
+
backup.chmod(0o600)
|
|
61
|
+
tmp = target.with_suffix('.passport-tmp')
|
|
62
|
+
tmp.write_text(json.dumps(merged, indent=2) + '\n')
|
|
63
|
+
tmp.chmod(0o600)
|
|
64
|
+
tmp.replace(target)
|
|
65
|
+
print(f'Hooks installed: {target}. Restart existing Codex sessions to load them.')
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def port_name(explicit):
|
|
69
|
+
if explicit:
|
|
70
|
+
return explicit
|
|
71
|
+
from serial.tools.list_ports import comports
|
|
72
|
+
ports = [p.device for p in comports() if p.vid == 0x303A and p.pid == 0x1001]
|
|
73
|
+
if len(ports) != 1:
|
|
74
|
+
raise RuntimeError('Connect one Passport by USB, or specify --port')
|
|
75
|
+
return ports[0]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def wire_event(event):
|
|
79
|
+
return {'id': event['id'], 'kind': event['kind'], 'project': clipped(event['project'],32),
|
|
80
|
+
'time': int(event['ts']), 'title': clipped(event.get('title') or event['project'],60),
|
|
81
|
+
'body': clipped(event.get('body') or '',120)}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def snapshot(store, seq, event=None):
|
|
85
|
+
now = int(time.time())
|
|
86
|
+
usage = store.get('usage', {})
|
|
87
|
+
windows = []
|
|
88
|
+
for window in usage.get('windows', [None, None]):
|
|
89
|
+
if not window or window['reset'] <= now or now-usage.get('updated',0) > 180:
|
|
90
|
+
windows.append([-1, window['minutes'] if window else 0, 0])
|
|
91
|
+
else:
|
|
92
|
+
windows.append([window['remaining'], window['minutes'], window['reset']])
|
|
93
|
+
frame = {'v': 1, 'seq': seq, 'now': now, **store.summary(), 'windows': windows,
|
|
94
|
+
'tokens': usage.get('tokens') if isinstance(usage.get('tokens'), int) else -1,
|
|
95
|
+
'today': usage.get('today') if isinstance(usage.get('today'), int) else -1,
|
|
96
|
+
'recent': [wire_event(e) for e in store.inbox(4)],
|
|
97
|
+
'event': wire_event(event) if event else None}
|
|
98
|
+
# JSON escaping can expand punctuation; keep the BLE line within its 2048-byte limit.
|
|
99
|
+
while frame['recent'] and len(json.dumps(frame,ensure_ascii=False,separators=(',',':')).encode())>1900:
|
|
100
|
+
frame['recent'].pop()
|
|
101
|
+
return frame
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def device_frame(frame):
|
|
105
|
+
"""The graphical device needs counts, quota and receipt IDs, not conversation text."""
|
|
106
|
+
result = {k: v for k, v in frame.items() if k != '_link'}
|
|
107
|
+
result['recent'] = []
|
|
108
|
+
event = frame.get('event')
|
|
109
|
+
result['event'] = None if not event else {
|
|
110
|
+
'id': event['id'], 'kind': event['kind'], 'project': '', 'time': event['time']}
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def run(args, store):
|
|
115
|
+
import serial
|
|
116
|
+
watcher = Sessions(args.codex_home, store)
|
|
117
|
+
client = UsageClient(args.codex)
|
|
118
|
+
lock = (args.state_dir / 'worker.lock').open('a+')
|
|
119
|
+
if os.name == 'posix':
|
|
120
|
+
import fcntl
|
|
121
|
+
try:
|
|
122
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
123
|
+
except BlockingIOError:
|
|
124
|
+
raise RuntimeError('A Passport worker is already running for this state directory')
|
|
125
|
+
usage_task = None
|
|
126
|
+
link = None
|
|
127
|
+
last_usage = last_scan = last_send = 0.0
|
|
128
|
+
seq = 0
|
|
129
|
+
pending_seq = 0
|
|
130
|
+
pending_event = None
|
|
131
|
+
incoming = bytearray()
|
|
132
|
+
acknowledged = False
|
|
133
|
+
try:
|
|
134
|
+
while True:
|
|
135
|
+
now = time.monotonic()
|
|
136
|
+
if now-last_scan >= 1:
|
|
137
|
+
watcher.poll()
|
|
138
|
+
last_scan = now
|
|
139
|
+
if usage_task is None and now-last_usage >= 60:
|
|
140
|
+
usage_task = asyncio.create_task(client.fetch())
|
|
141
|
+
last_usage = now
|
|
142
|
+
if usage_task and usage_task.done():
|
|
143
|
+
try:
|
|
144
|
+
store.put('usage', usage_task.result())
|
|
145
|
+
LOG.info('Account usage refreshed')
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
LOG.warning('Usage unavailable (%s); retrying in 60s', type(exc).__name__)
|
|
148
|
+
usage_task = None
|
|
149
|
+
if args.dry_run:
|
|
150
|
+
if usage_task:
|
|
151
|
+
try:
|
|
152
|
+
store.put('usage', await usage_task)
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
LOG.warning('Usage unavailable (%s)', type(exc).__name__)
|
|
155
|
+
usage_task = None
|
|
156
|
+
print(json.dumps(snapshot(store, 1), ensure_ascii=False, indent=2))
|
|
157
|
+
return
|
|
158
|
+
try:
|
|
159
|
+
if link is None:
|
|
160
|
+
link = serial.Serial(port_name(args.port), 115200, timeout=0, write_timeout=1)
|
|
161
|
+
incoming.clear()
|
|
162
|
+
pending_seq = 0
|
|
163
|
+
last_send = 0
|
|
164
|
+
LOG.info('USB connected: %s', link.port)
|
|
165
|
+
data = link.read(min(link.in_waiting, 8192))
|
|
166
|
+
incoming.extend(data)
|
|
167
|
+
while b'\n' in incoming:
|
|
168
|
+
line, _, rest = incoming.partition(b'\n')
|
|
169
|
+
incoming = bytearray(rest)
|
|
170
|
+
try:
|
|
171
|
+
reply = json.loads(line)
|
|
172
|
+
except ValueError:
|
|
173
|
+
continue
|
|
174
|
+
if not isinstance(reply, dict) or reply.get('v') != 1:
|
|
175
|
+
continue
|
|
176
|
+
if reply.get('ack') == pending_seq and pending_seq:
|
|
177
|
+
if pending_event:
|
|
178
|
+
store.delivered(pending_event['id'])
|
|
179
|
+
LOG.info('Notification delivered: #%d %s', pending_event['id'], pending_event['kind'])
|
|
180
|
+
pending_seq, pending_event = 0, None
|
|
181
|
+
acknowledged = True
|
|
182
|
+
if isinstance(reply.get('read'), int):
|
|
183
|
+
store.mark_read(min(reply['read'], store.summary()['latest']))
|
|
184
|
+
if len(incoming) > 16384:
|
|
185
|
+
incoming.clear()
|
|
186
|
+
if pending_seq and now-last_send > 5:
|
|
187
|
+
raise RuntimeError('Passport acknowledgement timed out')
|
|
188
|
+
event = store.pending()
|
|
189
|
+
if not pending_seq and (event or now-last_send >= 2):
|
|
190
|
+
seq = seq + 1 if seq < 2_000_000_000 else 1
|
|
191
|
+
payload = json.dumps(snapshot(store, seq, event), ensure_ascii=False, separators=(',', ':')).encode()+b'\n'
|
|
192
|
+
if len(payload) > 2048:
|
|
193
|
+
raise ValueError('Snapshot exceeds device frame size')
|
|
194
|
+
link.write(payload)
|
|
195
|
+
pending_seq, pending_event, last_send = seq, event, now
|
|
196
|
+
if args.once and acknowledged:
|
|
197
|
+
return
|
|
198
|
+
except (serial.SerialException, OSError, RuntimeError) as exc:
|
|
199
|
+
LOG.warning('USB disconnected (%s); reconnecting', type(exc).__name__)
|
|
200
|
+
if link:
|
|
201
|
+
link.close()
|
|
202
|
+
link = None
|
|
203
|
+
await asyncio.sleep(1)
|
|
204
|
+
await asyncio.sleep(0.1)
|
|
205
|
+
finally:
|
|
206
|
+
if link:
|
|
207
|
+
link.close()
|
|
208
|
+
if usage_task:
|
|
209
|
+
usage_task.cancel()
|
|
210
|
+
await asyncio.gather(usage_task, return_exceptions=True)
|
|
211
|
+
await client.close()
|
|
212
|
+
lock.close()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def main():
|
|
216
|
+
parser = argparse.ArgumentParser(description='Codex lifecycle inbox and account usage on AI Passport')
|
|
217
|
+
parser.add_argument('--state-dir', type=Path, default=state_dir())
|
|
218
|
+
parser.add_argument('--codex-home', type=Path, default=Path(os.environ.get('CODEX_HOME', str(Path.home()/'.codex'))))
|
|
219
|
+
sub = parser.add_subparsers(dest='command', required=True)
|
|
220
|
+
worker = sub.add_parser('run')
|
|
221
|
+
worker.add_argument('--port')
|
|
222
|
+
worker.add_argument('--codex', default='codex')
|
|
223
|
+
worker.add_argument('--dry-run', action='store_true')
|
|
224
|
+
worker.add_argument('--once', action='store_true', help='Exit after one device acknowledgement')
|
|
225
|
+
hook = sub.add_parser('hook', help='Consume Codex hook JSON from stdin, or notify JSON argument')
|
|
226
|
+
hook.add_argument('payload', nargs='?')
|
|
227
|
+
sub.add_parser('install-hooks')
|
|
228
|
+
relay = sub.add_parser('serve', help='Expose the authenticated relay to an Android WireGuard peer')
|
|
229
|
+
relay.add_argument('--host', default='127.0.0.1')
|
|
230
|
+
relay.add_argument('--port', type=int, default=18765)
|
|
231
|
+
relay.add_argument('--codex', default='codex')
|
|
232
|
+
relay.add_argument('--ble', metavar='DEVICE', help='Optional Passport BLE name/address; phone fallback on failure')
|
|
233
|
+
bluetooth = sub.add_parser('ble', help='Deliver the existing collector state directly over BLE')
|
|
234
|
+
bluetooth.add_argument('--device', required=True, help='Passport advertising name or OS Bluetooth address')
|
|
235
|
+
bluetooth.add_argument('--once', action='store_true')
|
|
236
|
+
sub.add_parser('status')
|
|
237
|
+
sub.add_parser('events')
|
|
238
|
+
args = parser.parse_args()
|
|
239
|
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s')
|
|
240
|
+
store = None
|
|
241
|
+
try:
|
|
242
|
+
store = Store(args.state_dir)
|
|
243
|
+
if args.command == 'run':
|
|
244
|
+
asyncio.run(run(args, store))
|
|
245
|
+
elif args.command == 'serve':
|
|
246
|
+
from .relay import serve
|
|
247
|
+
asyncio.run(serve(args, store))
|
|
248
|
+
elif args.command == 'ble':
|
|
249
|
+
from .ble import worker
|
|
250
|
+
from .routing import Routing
|
|
251
|
+
asyncio.run(worker(args.device, store, Routing(desktop=True), args.once))
|
|
252
|
+
elif args.command == 'hook':
|
|
253
|
+
raw = args.payload if args.payload is not None else sys.stdin.read(4*1024*1024)
|
|
254
|
+
store.ingest_hook(json.loads(raw))
|
|
255
|
+
elif args.command == 'install-hooks':
|
|
256
|
+
install_hooks(args)
|
|
257
|
+
elif args.command == 'status':
|
|
258
|
+
print(json.dumps(snapshot(store, 0), indent=2))
|
|
259
|
+
elif args.command == 'events':
|
|
260
|
+
print(json.dumps(store.recent(100), indent=2))
|
|
261
|
+
except KeyboardInterrupt:
|
|
262
|
+
pass
|
|
263
|
+
except Exception as exc:
|
|
264
|
+
if args.command == 'hook':
|
|
265
|
+
# Notification failures must never block or authorize a Codex tool.
|
|
266
|
+
return
|
|
267
|
+
parser.exit(1, f'{type(exc).__name__}: {exc}\n')
|
|
268
|
+
finally:
|
|
269
|
+
if store is not None:
|
|
270
|
+
store.close()
|
codex_passport/relay.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Authenticated LAN relay intended to be reached over the user's WireGuard tunnel."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import hmac
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import secrets
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
10
|
+
from .store import Store
|
|
11
|
+
from .sessions import Sessions
|
|
12
|
+
from .usage import UsageClient
|
|
13
|
+
from .routing import Routing
|
|
14
|
+
|
|
15
|
+
LOG = logging.getLogger('codex-passport')
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def token_file(directory):
|
|
19
|
+
path = directory / 'relay-token'
|
|
20
|
+
if not path.exists():
|
|
21
|
+
with path.open('x') as stream:
|
|
22
|
+
path.chmod(0o600)
|
|
23
|
+
stream.write(secrets.token_urlsafe(32))
|
|
24
|
+
return path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def handler_factory(directory, token, routing=None):
|
|
28
|
+
routing = routing or Routing()
|
|
29
|
+
class Handler(BaseHTTPRequestHandler):
|
|
30
|
+
def log_message(self, *_):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
def authorized(self):
|
|
34
|
+
return hmac.compare_digest(self.headers.get('Authorization', ''), 'Bearer ' + token)
|
|
35
|
+
|
|
36
|
+
def reply(self, status, value):
|
|
37
|
+
raw = json.dumps(value, ensure_ascii=False, separators=(',', ':')).encode()
|
|
38
|
+
self.send_response(status)
|
|
39
|
+
self.send_header('Content-Type', 'application/json')
|
|
40
|
+
self.send_header('Content-Length', str(len(raw)))
|
|
41
|
+
self.send_header('Cache-Control', 'no-store')
|
|
42
|
+
self.end_headers()
|
|
43
|
+
self.wfile.write(raw)
|
|
44
|
+
|
|
45
|
+
def do_GET(self):
|
|
46
|
+
if not self.authorized():
|
|
47
|
+
return self.reply(401, {'error': 'unauthorized'})
|
|
48
|
+
if self.path != '/v1/snapshot':
|
|
49
|
+
return self.reply(404, {'error': 'not found'})
|
|
50
|
+
from .cli import snapshot
|
|
51
|
+
store = Store(directory)
|
|
52
|
+
try:
|
|
53
|
+
seq = int(time.time() * 1000) % 2_000_000_000 + 1
|
|
54
|
+
frame = snapshot(store, seq, store.pending())
|
|
55
|
+
frame['_link'] = routing.view()
|
|
56
|
+
self.reply(200, frame)
|
|
57
|
+
finally:
|
|
58
|
+
store.close()
|
|
59
|
+
|
|
60
|
+
def do_POST(self):
|
|
61
|
+
if not self.authorized():
|
|
62
|
+
return self.reply(401, {'error': 'unauthorized'})
|
|
63
|
+
if self.path not in ('/v1/ack', '/v1/route'):
|
|
64
|
+
return self.reply(404, {'error': 'not found'})
|
|
65
|
+
try:
|
|
66
|
+
length = int(self.headers.get('Content-Length', '-1'))
|
|
67
|
+
if not 0 <= length <= 1024:
|
|
68
|
+
return self.reply(413, {'error': 'invalid length'})
|
|
69
|
+
self.connection.settimeout(5)
|
|
70
|
+
payload = json.loads(self.rfile.read(length))
|
|
71
|
+
if not isinstance(payload, dict):
|
|
72
|
+
raise ValueError()
|
|
73
|
+
except (ValueError, OSError):
|
|
74
|
+
return self.reply(400, {'error': 'invalid JSON'})
|
|
75
|
+
if self.path == '/v1/route':
|
|
76
|
+
try:
|
|
77
|
+
return self.reply(200, routing.select(payload.get('owner')))
|
|
78
|
+
except ValueError as exc:
|
|
79
|
+
return self.reply(400, {'error': str(exc)})
|
|
80
|
+
store = Store(directory)
|
|
81
|
+
try:
|
|
82
|
+
event = payload.get('event')
|
|
83
|
+
read = payload.get('read')
|
|
84
|
+
if isinstance(event, int) and not isinstance(event, bool) and event > 0:
|
|
85
|
+
store.delivered(event)
|
|
86
|
+
if isinstance(read, int) and not isinstance(read, bool) and read >= 0:
|
|
87
|
+
store.mark_read(min(read, store.summary()['latest']))
|
|
88
|
+
self.reply(200, {'ok': True})
|
|
89
|
+
finally:
|
|
90
|
+
store.close()
|
|
91
|
+
return Handler
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def serve(args, store):
|
|
95
|
+
token = token_file(args.state_dir).read_text().strip()
|
|
96
|
+
routing = Routing(desktop=bool(args.ble))
|
|
97
|
+
server = ThreadingHTTPServer((args.host, args.port), handler_factory(args.state_dir, token, routing))
|
|
98
|
+
server.daemon_threads = True
|
|
99
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
100
|
+
thread.start()
|
|
101
|
+
LOG.info('Relay listening at %s:%d; pairing token file: %s', args.host, args.port, token_file(args.state_dir))
|
|
102
|
+
watcher = Sessions(args.codex_home, store)
|
|
103
|
+
usage = UsageClient(args.codex)
|
|
104
|
+
task = None
|
|
105
|
+
ble_task = None
|
|
106
|
+
if args.ble:
|
|
107
|
+
from .ble import worker
|
|
108
|
+
ble_task = asyncio.create_task(worker(args.ble, store, routing))
|
|
109
|
+
last = 0
|
|
110
|
+
try:
|
|
111
|
+
while True:
|
|
112
|
+
watcher.poll()
|
|
113
|
+
if task is None and time.monotonic() - last >= 60:
|
|
114
|
+
task = asyncio.create_task(usage.fetch())
|
|
115
|
+
last = time.monotonic()
|
|
116
|
+
if task and task.done():
|
|
117
|
+
try:
|
|
118
|
+
store.put('usage', task.result())
|
|
119
|
+
LOG.info('Account usage refreshed')
|
|
120
|
+
except Exception as exc:
|
|
121
|
+
LOG.warning('Usage refresh unavailable (%s)', type(exc).__name__)
|
|
122
|
+
task = None
|
|
123
|
+
await asyncio.sleep(1)
|
|
124
|
+
finally:
|
|
125
|
+
if ble_task:
|
|
126
|
+
ble_task.cancel()
|
|
127
|
+
await asyncio.gather(ble_task, return_exceptions=True)
|
|
128
|
+
if task:
|
|
129
|
+
task.cancel()
|
|
130
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
131
|
+
await usage.close()
|
|
132
|
+
server.shutdown()
|
|
133
|
+
server.server_close()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""One active BLE owner, with a bounded desktop handoff and sticky fallback.
|
|
2
|
+
|
|
3
|
+
No location inference: a reachable VPN server does not imply Bluetooth proximity.
|
|
4
|
+
"""
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Routing:
|
|
10
|
+
def __init__(self, desktop=False, clock=time.monotonic):
|
|
11
|
+
self.clock = clock
|
|
12
|
+
self.lock = threading.Lock()
|
|
13
|
+
self.desktop = desktop
|
|
14
|
+
self.owner = 'desktop' if desktop else 'phone'
|
|
15
|
+
self.state = 'connecting' if desktop else 'idle'
|
|
16
|
+
self.deadline = clock() + 180 if desktop else 0
|
|
17
|
+
self.generation = 0
|
|
18
|
+
|
|
19
|
+
def view(self):
|
|
20
|
+
with self.lock:
|
|
21
|
+
if self.owner == 'desktop' and self.clock() > self.deadline:
|
|
22
|
+
self.owner, self.state = 'phone', 'fallback'
|
|
23
|
+
self.generation += 1
|
|
24
|
+
return dict(owner=self.owner, state=self.state,
|
|
25
|
+
desktopAvailable=self.desktop, generation=self.generation)
|
|
26
|
+
|
|
27
|
+
def select(self, owner):
|
|
28
|
+
if owner not in ('phone', 'desktop') or (owner == 'desktop' and not self.desktop):
|
|
29
|
+
raise ValueError('Desktop BLE is not configured' if owner == 'desktop' else 'Invalid route')
|
|
30
|
+
with self.lock:
|
|
31
|
+
self.owner = owner
|
|
32
|
+
self.state = 'connecting' if owner == 'desktop' else 'idle'
|
|
33
|
+
self.deadline = self.clock() + 180
|
|
34
|
+
self.generation += 1
|
|
35
|
+
return self.view()
|
|
36
|
+
|
|
37
|
+
def acknowledged(self, generation):
|
|
38
|
+
with self.lock:
|
|
39
|
+
if generation == self.generation and self.owner == 'desktop':
|
|
40
|
+
self.state, self.deadline = 'connected', self.clock() + 60
|
|
41
|
+
|
|
42
|
+
def failed(self, generation):
|
|
43
|
+
with self.lock:
|
|
44
|
+
if generation == self.generation:
|
|
45
|
+
self.owner, self.state = 'phone', 'fallback'
|
|
46
|
+
self.generation += 1
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Incremental all-session fallback; hooks are the supported event interface."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import sqlite3
|
|
6
|
+
from contextlib import closing
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from .store import Store
|
|
10
|
+
|
|
11
|
+
MAX_LINE = 4 * 1024 * 1024
|
|
12
|
+
|
|
13
|
+
def internal_session(meta):
|
|
14
|
+
source = meta.get('source')
|
|
15
|
+
# These are hidden approval-review workers, not user-visible conversations.
|
|
16
|
+
if meta.get('thread_source') in ('guardian_review', 'memory_consolidation'):
|
|
17
|
+
return True
|
|
18
|
+
if isinstance(source, dict):
|
|
19
|
+
child = source.get('subagent')
|
|
20
|
+
return isinstance(child, dict) and child.get('other') in ('guardian', 'memory_consolidation')
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Sessions:
|
|
25
|
+
def __init__(self, home: Path, store: Store):
|
|
26
|
+
self.home, self.store = home, store
|
|
27
|
+
self.initialized = bool(store.get('sessions_initialized', False))
|
|
28
|
+
self.baseline = store.get('watch_start', time.time())
|
|
29
|
+
store.put('watch_start', self.baseline)
|
|
30
|
+
|
|
31
|
+
def poll(self):
|
|
32
|
+
paths = sorted((self.home / 'sessions').rglob('*.jsonl'))
|
|
33
|
+
# Archived sessions can finish while being moved. Track them too.
|
|
34
|
+
paths += sorted((self.home / 'archived_sessions').rglob('*.jsonl'))
|
|
35
|
+
for path in paths:
|
|
36
|
+
try:
|
|
37
|
+
self._read(path)
|
|
38
|
+
except (OSError, UnicodeError):
|
|
39
|
+
continue
|
|
40
|
+
self.initialized = True
|
|
41
|
+
self.store.put('sessions_initialized', True)
|
|
42
|
+
|
|
43
|
+
def _read(self, path):
|
|
44
|
+
stat = path.stat()
|
|
45
|
+
key = f'file:{stat.st_dev}:{stat.st_ino}'
|
|
46
|
+
cursor = self.store.get(key, {})
|
|
47
|
+
if cursor.get('ignored'):
|
|
48
|
+
return
|
|
49
|
+
if not cursor.get('checked'):
|
|
50
|
+
with path.open('rb') as first:
|
|
51
|
+
try: meta=json.loads(first.readline(MAX_LINE)).get('payload',{})
|
|
52
|
+
except (ValueError,AttributeError): meta={}
|
|
53
|
+
if internal_session(meta):
|
|
54
|
+
self.store.hide_thread(meta.get('id',''))
|
|
55
|
+
self.store.put(key,{'ignored':True})
|
|
56
|
+
return
|
|
57
|
+
offset = cursor.get('offset', 0)
|
|
58
|
+
thread, turn = cursor.get('thread', path.stem), cursor.get('turn', '')
|
|
59
|
+
project = cursor.get('project', '')
|
|
60
|
+
historical = not self.initialized and not cursor
|
|
61
|
+
if stat.st_size < offset or cursor.get('inode', stat.st_ino) != stat.st_ino:
|
|
62
|
+
offset = 0
|
|
63
|
+
if offset == stat.st_size and cursor:
|
|
64
|
+
if not cursor.get('checked'):
|
|
65
|
+
cursor['checked']=True
|
|
66
|
+
self.store.put(key,cursor)
|
|
67
|
+
return
|
|
68
|
+
with path.open('rb') as stream:
|
|
69
|
+
if not cursor:
|
|
70
|
+
first = stream.readline(MAX_LINE)
|
|
71
|
+
try:
|
|
72
|
+
meta = json.loads(first).get('payload', {})
|
|
73
|
+
if internal_session(meta):
|
|
74
|
+
self.store.put(key, {'ignored': True})
|
|
75
|
+
return
|
|
76
|
+
thread = meta.get('id', thread)
|
|
77
|
+
project = Path(meta.get('cwd', '')).name
|
|
78
|
+
except (ValueError, AttributeError, TypeError):
|
|
79
|
+
pass
|
|
80
|
+
if historical:
|
|
81
|
+
offset = max(0, stat.st_size - 2 * MAX_LINE)
|
|
82
|
+
stream.seek(offset)
|
|
83
|
+
if offset:
|
|
84
|
+
stream.readline()
|
|
85
|
+
offset = stream.tell()
|
|
86
|
+
stream.seek(offset)
|
|
87
|
+
# Bounded work per file per poll avoids starving the device heartbeat.
|
|
88
|
+
end = min(stat.st_size, offset + 8 * MAX_LINE)
|
|
89
|
+
while stream.tell() < end:
|
|
90
|
+
before = stream.tell()
|
|
91
|
+
raw = stream.readline(MAX_LINE + 1)
|
|
92
|
+
if not raw.endswith(b'\n'):
|
|
93
|
+
if len(raw) > MAX_LINE:
|
|
94
|
+
while raw and not raw.endswith(b'\n'):
|
|
95
|
+
raw = stream.readline(MAX_LINE + 1)
|
|
96
|
+
offset = stream.tell()
|
|
97
|
+
continue
|
|
98
|
+
offset = before # Never consume a half-written JSON record.
|
|
99
|
+
break
|
|
100
|
+
offset = stream.tell()
|
|
101
|
+
try:
|
|
102
|
+
record = json.loads(raw)
|
|
103
|
+
except ValueError:
|
|
104
|
+
continue
|
|
105
|
+
if not isinstance(record, dict):
|
|
106
|
+
continue
|
|
107
|
+
p = record.get('payload')
|
|
108
|
+
if not isinstance(p, dict):
|
|
109
|
+
continue
|
|
110
|
+
stamp = time.time()
|
|
111
|
+
try:
|
|
112
|
+
stamp = datetime.fromisoformat(record['timestamp'].replace('Z', '+00:00')).timestamp()
|
|
113
|
+
except (KeyError, ValueError, AttributeError):
|
|
114
|
+
pass
|
|
115
|
+
if record.get('type') == 'session_meta':
|
|
116
|
+
if internal_session(p):
|
|
117
|
+
self.store.put(key, {'ignored': True})
|
|
118
|
+
return
|
|
119
|
+
thread = p.get('id', thread)
|
|
120
|
+
project = Path(p.get('cwd', '')).name
|
|
121
|
+
if record.get('type') == 'turn_context':
|
|
122
|
+
turn = p.get('turn_id', turn)
|
|
123
|
+
kind = None
|
|
124
|
+
identity = ''
|
|
125
|
+
if record.get('type') == 'event_msg':
|
|
126
|
+
name = p.get('type')
|
|
127
|
+
turn = p.get('turn_id', turn)
|
|
128
|
+
kind = {'task_started': 'started', 'task_complete': 'completed',
|
|
129
|
+
'turn_aborted': 'interrupted', 'task_failed': 'failed',
|
|
130
|
+
'error': 'failed'}.get(name)
|
|
131
|
+
if record.get('type') == 'response_item':
|
|
132
|
+
if p.get('type') in ('function_call', 'custom_tool_call') and 'request_user_input' in p.get('name', ''):
|
|
133
|
+
kind, identity = 'input', p.get('call_id', '')
|
|
134
|
+
if p.get('type') in ('function_call_output', 'custom_tool_call_output') and not historical:
|
|
135
|
+
self.store.resume(thread, turn, identity=p.get("call_id", ""))
|
|
136
|
+
if kind:
|
|
137
|
+
self.store.event(thread, turn, kind, project, identity, stamp, historical or stamp < self.baseline)
|
|
138
|
+
title=''
|
|
139
|
+
for database in sorted(self.home.glob('state_*.sqlite'),reverse=True)[:1]:
|
|
140
|
+
try:
|
|
141
|
+
with closing(sqlite3.connect(database.resolve().as_uri()+'?mode=ro',uri=True)) as db:
|
|
142
|
+
row=db.execute('SELECT title FROM threads WHERE id=?',(thread,)).fetchone()
|
|
143
|
+
if row:title=row[0]
|
|
144
|
+
except sqlite3.Error:pass
|
|
145
|
+
body=p.get('last_agent_message','') if kind=='completed' else ''
|
|
146
|
+
if kind=='input':
|
|
147
|
+
try:
|
|
148
|
+
args=json.loads(p.get('arguments','{}'))
|
|
149
|
+
questions=args.get('questions',[])
|
|
150
|
+
if questions and isinstance(questions[0],dict):
|
|
151
|
+
body=questions[0].get('question') or questions[0].get('title') or ''
|
|
152
|
+
except (ValueError,TypeError,AttributeError):pass
|
|
153
|
+
self.store.decorate(thread,title=title,body=body)
|
|
154
|
+
self.store.put(key, {'offset': offset, 'thread': thread, 'turn': turn,
|
|
155
|
+
'project': project, 'inode': stat.st_ino, 'checked':True})
|
codex_passport/store.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Durable metadata-only inbox shared by hooks and the device worker."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
ALERT_KINDS = {"completed", "failed", "interrupted", "approval", "input"}
|
|
11
|
+
|
|
12
|
+
LABELS = {
|
|
13
|
+
"started": "Task started", "completed": "Task complete", "failed": "Task failed",
|
|
14
|
+
"interrupted": "Task interrupted", "approval": "Approval needed",
|
|
15
|
+
"input": "Your input needed", "session": "Session opened", "closed": "Session closed",
|
|
16
|
+
"compacting": "Context compacting", "compacted": "Context compacted",
|
|
17
|
+
"unknown": "Status unavailable",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def clipped(value, limit=60):
|
|
22
|
+
# Bound UTF-8 bytes without splitting a character.
|
|
23
|
+
return ''.join(c for c in str(value) if ord(c)>=32).encode('utf-8')[:limit].decode('utf-8',errors='ignore')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Store:
|
|
27
|
+
def __init__(self, directory: Path):
|
|
28
|
+
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
29
|
+
self.db = sqlite3.connect(directory / 'state.sqlite3', timeout=2)
|
|
30
|
+
self.db.row_factory = sqlite3.Row
|
|
31
|
+
self.db.execute('PRAGMA journal_mode=WAL')
|
|
32
|
+
self.db.executescript('''
|
|
33
|
+
CREATE TABLE IF NOT EXISTS events(
|
|
34
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT UNIQUE, thread TEXT,
|
|
35
|
+
kind TEXT, project TEXT, ts REAL, delivered INTEGER DEFAULT 0,
|
|
36
|
+
read INTEGER DEFAULT 0);
|
|
37
|
+
CREATE TABLE IF NOT EXISTS sessions(
|
|
38
|
+
thread TEXT PRIMARY KEY, turn TEXT, status TEXT, project TEXT, updated REAL);
|
|
39
|
+
CREATE TABLE IF NOT EXISTS waits(thread TEXT, turn TEXT, identity TEXT,
|
|
40
|
+
PRIMARY KEY(thread,turn,identity));
|
|
41
|
+
CREATE TABLE IF NOT EXISTS kv(key TEXT PRIMARY KEY, value TEXT);
|
|
42
|
+
''')
|
|
43
|
+
columns={r[1] for r in self.db.execute('PRAGMA table_info(events)')}
|
|
44
|
+
for col in ('title','body'):
|
|
45
|
+
if col not in columns:
|
|
46
|
+
self.db.execute(f"ALTER TABLE events ADD COLUMN {col} TEXT DEFAULT ''")
|
|
47
|
+
if self.get('notification_policy',0)<2:
|
|
48
|
+
self.db.execute("UPDATE events SET delivered=1,read=1 WHERE kind NOT IN ('completed','failed','interrupted','approval','input')")
|
|
49
|
+
self.put('notification_policy',2)
|
|
50
|
+
self.db.commit()
|
|
51
|
+
|
|
52
|
+
def hide_thread(self, thread):
|
|
53
|
+
self.db.execute('DELETE FROM events WHERE thread=?',(thread,))
|
|
54
|
+
self.db.execute('DELETE FROM sessions WHERE thread=?',(thread,))
|
|
55
|
+
self.db.execute('DELETE FROM waits WHERE thread=?',(thread,))
|
|
56
|
+
self.db.commit()
|
|
57
|
+
|
|
58
|
+
def decorate(self, thread, title='', body=''):
|
|
59
|
+
# Retain human context, redact obvious credentials and omit code blocks.
|
|
60
|
+
def clean(text,limit):
|
|
61
|
+
text=re.sub(r'(?s)```.*?```', '', str(text))
|
|
62
|
+
text=re.sub(r'(?i)(?:sk-|pypi-|ghp_)[A-Za-z0-9_-]+','[redacted]',text)
|
|
63
|
+
return clipped(' '.join(text.split()),limit)
|
|
64
|
+
row=self.db.execute('SELECT id FROM events WHERE thread=? ORDER BY id DESC LIMIT 1',(thread,)).fetchone()
|
|
65
|
+
if row:
|
|
66
|
+
if title:self.db.execute('UPDATE events SET title=? WHERE id=?',(clean(title,60),row[0]))
|
|
67
|
+
if body:self.db.execute('UPDATE events SET body=? WHERE id=?',(clean(body,120),row[0]))
|
|
68
|
+
self.db.commit()
|
|
69
|
+
|
|
70
|
+
def get(self, key, default=None):
|
|
71
|
+
row = self.db.execute('SELECT value FROM kv WHERE key=?', (key,)).fetchone()
|
|
72
|
+
return json.loads(row[0]) if row else default
|
|
73
|
+
|
|
74
|
+
def put(self, key, value):
|
|
75
|
+
self.db.execute('INSERT OR REPLACE INTO kv VALUES(?,?)', (key, json.dumps(value)))
|
|
76
|
+
self.db.commit()
|
|
77
|
+
|
|
78
|
+
def event(self, thread, turn, kind, project='', identity='', ts=None, silent=False):
|
|
79
|
+
if kind not in LABELS or not thread:
|
|
80
|
+
return
|
|
81
|
+
ts = time.time() if ts is None else ts
|
|
82
|
+
old = self.db.execute('SELECT * FROM sessions WHERE thread=?', (thread,)).fetchone()
|
|
83
|
+
turn = turn or (old['turn'] if old else '')
|
|
84
|
+
project = clipped(project or (old['project'] if old else thread[:8]), 32)
|
|
85
|
+
key = hashlib.sha256(f'{thread}|{turn}|{kind}|{identity}'.encode()).hexdigest()
|
|
86
|
+
# Historical tail recovery must never overwrite a newer live hook.
|
|
87
|
+
if not old or ts >= old['updated']:
|
|
88
|
+
status = kind
|
|
89
|
+
if kind == 'session':
|
|
90
|
+
status = old['status'] if old else 'idle'
|
|
91
|
+
if kind == 'compacted':
|
|
92
|
+
status = 'started'
|
|
93
|
+
if kind in ('approval', 'input'):
|
|
94
|
+
self.db.execute('INSERT OR IGNORE INTO waits VALUES(?,?,?)',(thread,turn,identity))
|
|
95
|
+
elif kind in ('completed', 'failed', 'interrupted', 'closed', 'started'):
|
|
96
|
+
self.db.execute('DELETE FROM waits WHERE thread=? AND turn!=?',(thread,turn))
|
|
97
|
+
if kind != 'started':
|
|
98
|
+
self.db.execute('DELETE FROM waits WHERE thread=?',(thread,))
|
|
99
|
+
self.db.execute('INSERT OR REPLACE INTO sessions VALUES(?,?,?,?,?)',
|
|
100
|
+
(thread, turn, status, project, ts))
|
|
101
|
+
if not silent:
|
|
102
|
+
quiet = int(kind not in ALERT_KINDS)
|
|
103
|
+
self.db.execute('INSERT OR IGNORE INTO events(key,thread,kind,project,ts,delivered,read) VALUES(?,?,?,?,?,?,?)',
|
|
104
|
+
(key, thread, kind, project, ts, quiet, quiet))
|
|
105
|
+
self.db.commit()
|
|
106
|
+
|
|
107
|
+
def resume(self, thread, turn='', project='', identity=''):
|
|
108
|
+
row = self.db.execute('SELECT * FROM sessions WHERE thread=?', (thread,)).fetchone()
|
|
109
|
+
if identity:
|
|
110
|
+
self.db.execute('DELETE FROM waits WHERE thread=? AND identity=?',(thread,identity))
|
|
111
|
+
pending = self.db.execute('SELECT count(*) FROM waits WHERE thread=?',(thread,)).fetchone()[0]
|
|
112
|
+
if not pending and row and (not turn or turn == row['turn']) and row['status'] in ('approval', 'input', 'compacting'):
|
|
113
|
+
self.db.execute("UPDATE sessions SET status='started',updated=? WHERE thread=?",
|
|
114
|
+
(time.time(), thread))
|
|
115
|
+
self.db.commit()
|
|
116
|
+
|
|
117
|
+
def ingest_hook(self, value):
|
|
118
|
+
if not isinstance(value, dict):
|
|
119
|
+
return
|
|
120
|
+
from .sessions import internal_session
|
|
121
|
+
transcript=value.get('transcript_path')
|
|
122
|
+
if transcript:
|
|
123
|
+
try:
|
|
124
|
+
with Path(transcript).open('rb') as stream:
|
|
125
|
+
meta=json.loads(stream.readline(4*1024*1024)).get('payload',{})
|
|
126
|
+
if internal_session(meta):
|
|
127
|
+
self.hide_thread(meta.get('id',''))
|
|
128
|
+
return
|
|
129
|
+
except (OSError, ValueError, AttributeError):
|
|
130
|
+
pass
|
|
131
|
+
if value.get('agent_type') in ('guardian','guardian_review'):
|
|
132
|
+
return
|
|
133
|
+
event = value.get('hook_event_name', value.get('type', ''))
|
|
134
|
+
thread = str(value.get('agent_id') or value.get('thread-id') or value.get('session_id') or '')[:128]
|
|
135
|
+
turn = str(value.get('turn_id') or value.get('turn-id') or '')[:128]
|
|
136
|
+
project = Path(str(value.get('cwd') or '')).name
|
|
137
|
+
tool = str(value.get('tool_name') or '')
|
|
138
|
+
mapping = {'SessionStart': 'session', 'UserPromptSubmit': 'started', 'Stop': 'completed',
|
|
139
|
+
'SubagentStart': 'started', 'SubagentStop': 'completed', 'Interrupt': 'interrupted',
|
|
140
|
+
'SessionEnd': 'closed', 'PermissionRequest': 'approval',
|
|
141
|
+
'PreCompact': 'compacting', 'PostCompact': 'compacted',
|
|
142
|
+
'agent-turn-complete': 'completed'}
|
|
143
|
+
if event == 'PostToolUse':
|
|
144
|
+
identity = str(value.get('tool_use_id') or '')
|
|
145
|
+
hashed = hashlib.sha256(json.dumps(value.get('tool_input', {}), sort_keys=True).encode()).hexdigest()
|
|
146
|
+
self.resume(thread, turn, project, identity)
|
|
147
|
+
self.resume(thread, turn, project, hashed)
|
|
148
|
+
return
|
|
149
|
+
kind = mapping.get(event)
|
|
150
|
+
if event == 'PreToolUse' and ('request_user_input' in tool or 'requestUserInput' in tool):
|
|
151
|
+
kind = 'input'
|
|
152
|
+
if not kind:
|
|
153
|
+
return
|
|
154
|
+
identity = ''
|
|
155
|
+
if kind in ('approval', 'input'):
|
|
156
|
+
# Raw tool arguments never enter storage, only their one-way identity hash.
|
|
157
|
+
identity = str(value.get('tool_use_id') or hashlib.sha256(
|
|
158
|
+
json.dumps(value.get('tool_input', {}), sort_keys=True).encode()).hexdigest())
|
|
159
|
+
if kind in ('session', 'closed'):
|
|
160
|
+
identity = str(int(time.time()) // 5)
|
|
161
|
+
self.event(thread, turn, kind, project, identity)
|
|
162
|
+
body=value.get('last_assistant_message') or value.get('last-assistant-message') or ''
|
|
163
|
+
if kind=='input':
|
|
164
|
+
tool_input=value.get('tool_input')
|
|
165
|
+
questions=tool_input.get('questions',[]) if isinstance(tool_input,dict) else []
|
|
166
|
+
if questions and isinstance(questions[0],dict):
|
|
167
|
+
body=questions[0].get('question') or questions[0].get('title') or ''
|
|
168
|
+
self.decorate(thread,body=body)
|
|
169
|
+
|
|
170
|
+
def pending(self):
|
|
171
|
+
row = self.db.execute('SELECT * FROM events WHERE delivered=0 ORDER BY id LIMIT 1').fetchone()
|
|
172
|
+
return dict(row) if row else None
|
|
173
|
+
|
|
174
|
+
def delivered(self, event_id):
|
|
175
|
+
self.db.execute('UPDATE events SET delivered=1 WHERE id=?', (event_id,))
|
|
176
|
+
self.db.commit()
|
|
177
|
+
|
|
178
|
+
def mark_read(self, event_id):
|
|
179
|
+
self.db.execute('UPDATE events SET read=1 WHERE id<=?', (event_id,))
|
|
180
|
+
self.db.commit()
|
|
181
|
+
|
|
182
|
+
def summary(self):
|
|
183
|
+
counts = dict(self.db.execute('SELECT status,count(*) FROM sessions WHERE updated>? GROUP BY status', (time.time()-86400,)))
|
|
184
|
+
unread = self.db.execute('SELECT count(*) FROM events WHERE read=0').fetchone()[0]
|
|
185
|
+
latest = self.db.execute('SELECT coalesce(max(id),0) FROM events').fetchone()[0]
|
|
186
|
+
return {'running': counts.get('started', 0) + counts.get('compacting', 0),
|
|
187
|
+
'waiting': counts.get('approval', 0) + counts.get('input', 0),
|
|
188
|
+
'unread': unread, 'latest': latest}
|
|
189
|
+
|
|
190
|
+
def inbox(self, limit=4):
|
|
191
|
+
return [dict(r) for r in self.db.execute(
|
|
192
|
+
"SELECT * FROM events WHERE id IN (SELECT max(id) FROM events WHERE kind IN ('completed','failed','interrupted','approval','input') GROUP BY thread) ORDER BY id DESC LIMIT ?",(limit,))]
|
|
193
|
+
|
|
194
|
+
def recent(self, limit=4):
|
|
195
|
+
return [dict(r) for r in self.db.execute('SELECT * FROM events ORDER BY id DESC LIMIT ?', (limit,))]
|
|
196
|
+
|
|
197
|
+
def close(self):
|
|
198
|
+
self.db.close()
|
codex_passport/usage.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Read-only JSON-RPC adapter. No authentication files or tokens are inspected."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import time
|
|
6
|
+
from . import __version__
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def normalize(response):
|
|
10
|
+
buckets = response.get('rateLimitsByLimitId') or {}
|
|
11
|
+
value = buckets.get('codex') or response.get('rateLimits') or {}
|
|
12
|
+
windows = []
|
|
13
|
+
for name in ('primary', 'secondary'):
|
|
14
|
+
w = value.get(name)
|
|
15
|
+
if not isinstance(w, dict):
|
|
16
|
+
windows.append(None)
|
|
17
|
+
continue
|
|
18
|
+
used, duration, reset = w.get('usedPercent'), w.get('windowDurationMins'), w.get('resetsAt')
|
|
19
|
+
if (isinstance(used, bool) or not isinstance(used, (float, int)) or not math.isfinite(used)
|
|
20
|
+
or not 0 <= used <= 100 or not isinstance(duration, int) or duration <= 0
|
|
21
|
+
or not isinstance(reset, int) or reset <= 0):
|
|
22
|
+
windows.append(None)
|
|
23
|
+
else:
|
|
24
|
+
windows.append({'remaining': round(100-used), 'minutes': duration, 'reset': reset})
|
|
25
|
+
return {'windows': windows, 'updated': int(time.time()), 'plan': str(value.get('planType') or 'unknown')[:24]}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UsageClient:
|
|
29
|
+
def __init__(self, executable='codex'):
|
|
30
|
+
self.executable = executable
|
|
31
|
+
self.process = None
|
|
32
|
+
self.serial = 0
|
|
33
|
+
|
|
34
|
+
async def close(self):
|
|
35
|
+
if self.process is not None:
|
|
36
|
+
if self.process.returncode is None:
|
|
37
|
+
self.process.terminate()
|
|
38
|
+
try:
|
|
39
|
+
await asyncio.wait_for(self.process.wait(), 2)
|
|
40
|
+
except asyncio.TimeoutError:
|
|
41
|
+
self.process.kill()
|
|
42
|
+
await self.process.wait()
|
|
43
|
+
self.process = None
|
|
44
|
+
|
|
45
|
+
async def request(self, method, params=None):
|
|
46
|
+
self.serial += 1
|
|
47
|
+
message = {'id': self.serial, 'method': method}
|
|
48
|
+
if params is not None:
|
|
49
|
+
message['params'] = params
|
|
50
|
+
self.process.stdin.write((json.dumps(message)+'\n').encode())
|
|
51
|
+
await self.process.stdin.drain()
|
|
52
|
+
async def response():
|
|
53
|
+
while True:
|
|
54
|
+
line = await self.process.stdout.readline()
|
|
55
|
+
if not line:
|
|
56
|
+
raise RuntimeError('Codex app-server closed')
|
|
57
|
+
data = json.loads(line)
|
|
58
|
+
if data.get('id') != self.serial or 'method' in data:
|
|
59
|
+
continue
|
|
60
|
+
if 'error' in data:
|
|
61
|
+
raise RuntimeError('Codex request unavailable: ' + method)
|
|
62
|
+
return data.get('result', {})
|
|
63
|
+
return await asyncio.wait_for(response(), 12)
|
|
64
|
+
|
|
65
|
+
async def fetch(self):
|
|
66
|
+
try:
|
|
67
|
+
if self.process is None or self.process.returncode is not None:
|
|
68
|
+
self.process = await asyncio.create_subprocess_exec(
|
|
69
|
+
self.executable, 'app-server', stdin=asyncio.subprocess.PIPE,
|
|
70
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
|
|
71
|
+
limit=4*1024*1024)
|
|
72
|
+
await self.request('initialize', {'clientInfo': {'name': 'codex-passport-sync', 'version': __version__}})
|
|
73
|
+
self.process.stdin.write(b'{"method":"initialized"}\n')
|
|
74
|
+
await self.process.stdin.drain()
|
|
75
|
+
limits = normalize(await self.request('account/rateLimits/read'))
|
|
76
|
+
try:
|
|
77
|
+
activity = await self.request('account/usage/read')
|
|
78
|
+
limits['tokens'] = (activity.get('summary') or {}).get('lifetimeTokens')
|
|
79
|
+
today = time.strftime('%Y-%m-%d')
|
|
80
|
+
limits['today'] = next((x.get('tokens') for x in activity.get('dailyUsageBuckets') or []
|
|
81
|
+
if x.get('startDate') == today), None)
|
|
82
|
+
except RuntimeError:
|
|
83
|
+
pass # Older Codex versions may not expose token-activity summaries.
|
|
84
|
+
return limits
|
|
85
|
+
except BaseException:
|
|
86
|
+
await self.close()
|
|
87
|
+
raise
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: codex-passport-sync
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Local Codex lifecycle notifications and usage for FoloToy AI Passport
|
|
5
|
+
Project-URL: Homepage, https://github.com/JuneLeGency/codex-passport
|
|
6
|
+
Project-URL: Documentation, https://github.com/JuneLeGency/codex-passport/blob/main/docs/GETTING_STARTED.zh_CN.md
|
|
7
|
+
Project-URL: Repository, https://github.com/JuneLeGency/codex-passport
|
|
8
|
+
Project-URL: Issues, https://github.com/JuneLeGency/codex-passport/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/JuneLeGency/codex-passport/releases
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Requires-Dist: pyserial<4,>=3.5
|
|
14
|
+
Provides-Extra: ble
|
|
15
|
+
Requires-Dist: bleak<4,>=1.1; extra == 'ble'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Codex Passport
|
|
19
|
+
|
|
20
|
+
Codex session notifications and quota visualization on FoloToy AI Passport, with an Android companion.
|
|
21
|
+
|
|
22
|
+
**[从零开始:中文一步步入门](https://github.com/JuneLeGency/codex-passport/blob/main/docs/GETTING_STARTED.zh_CN.md)** · [中文介绍](https://github.com/JuneLeGency/codex-passport/blob/main/README.zh_CN.md) · [Python / uvx guide](https://github.com/JuneLeGency/codex-passport/blob/main/docs/PYTHON.md) · [Downloads](https://github.com/JuneLeGency/codex-passport/releases)
|
|
23
|
+
|
|
24
|
+
<img src="https://raw.githubusercontent.com/JuneLeGency/codex-passport/main/docs/images/dashboard-example.png" alt="Passport graphical quota dashboard" width="240">
|
|
25
|
+
|
|
26
|
+
Synthetic quota fixture rendered on the physical device; no account or conversation data.
|
|
27
|
+
|
|
28
|
+
## What it does
|
|
29
|
+
|
|
30
|
+
- Computer → encrypted BLE → Passport, or computer → private LAN/WireGuard → Android → encrypted BLE → Passport.
|
|
31
|
+
- Meaningful completion, failure, interruption, approval and input notifications across local Codex sessions. Starts and housekeeping update silently; internal guardian/memory workers are excluded.
|
|
32
|
+
- Durable delivery queue, device acknowledgements, offline replay, and one active BLE sender with phone fallback.
|
|
33
|
+
- Remaining quota compared with remaining cycle time. Orange means faster consumption, green balanced, blue slower.
|
|
34
|
+
- Native 240×320 Passport dashboard and physical-button controls. Material 3 Expressive Android overview, notification and connection pages.
|
|
35
|
+
|
|
36
|
+
Phone notifications show bounded titles and summaries for the four most recent sessions. Passport receives quota, counts and receipt IDs, with no conversation text.
|
|
37
|
+
**This version does not resume conversations, send replies or approve actions.** Those stay in Codex.
|
|
38
|
+
Hooks need existing sessions to restart after installation; incremental logs are a compatibility fallback, not a stable API or Codex Mobile push integration.
|
|
39
|
+
Other computers need their own collectors.
|
|
40
|
+
|
|
41
|
+
## Get started
|
|
42
|
+
|
|
43
|
+
The [beginner guide](https://github.com/JuneLeGency/codex-passport/blob/main/docs/GETTING_STARTED.zh_CN.md) covers tool installation, backup/app-only flashing, APK installation without ADB, relay configuration, pairing, a real notification test, startup, switching, troubleshooting and removal.
|
|
44
|
+
It uses the verified macOS + Android + Passport setup. Linux host deployment needs platform adaptation; Windows host deployment has not been validated.
|
|
45
|
+
|
|
46
|
+
Download the ready-made [Android APK and Passport firmware v0.1.0](https://github.com/JuneLeGency/codex-passport/releases/tag/v0.1.0).
|
|
47
|
+
Python 0.1.1 is a documentation/packaging update compatible with those device files; no device reinstall is needed for this update.
|
|
48
|
+
The APK is debug-signed. Back up the device before flashing and write **only the app at 0x10000 on the documented hardware layout**, preserving its other partitions.
|
|
49
|
+
|
|
50
|
+
## Python installation
|
|
51
|
+
|
|
52
|
+
Requires Python 3.10+ and a working, logged-in Codex CLI. Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then try:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
uvx --from 'codex-passport-sync==0.1.1' codex-passport --help
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
For persistent hooks, install a persistent tool environment instead:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
uv tool install 'codex-passport-sync[ble]==0.1.1'
|
|
62
|
+
uv tool update-shell
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Open a new terminal, then:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
codex-passport install-hooks
|
|
69
|
+
export COMPUTER_LAN_IP="replace-with-this-computers-private-IP"
|
|
70
|
+
codex-passport serve --host "$COMPUTER_LAN_IP" --port 18765
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Keep that terminal open. Configure Android's Connection page with `http://YOUR_COMPUTER_IP:18765` and the contents of `~/.local/state/codex-passport/relay-token`.
|
|
74
|
+
Allow Bluetooth permissions and enter the current six-digit Passport code in the phone's system pairing dialog.
|
|
75
|
+
Restart existing Codex sessions to load the hooks. The address is the relay API, not wireless ADB or a WireGuard endpoint.
|
|
76
|
+
|
|
77
|
+
Add `--ble "Passport-XXXXXX"` using your device's actual advertised name for computer BLE. First pairing requires the computer's OS dialog and Bluetooth permission.
|
|
78
|
+
The `[ble]` extra is required for this route. Select the sender on Android's Connection page; a failed computer connection falls back to the phone and stays there until selected again.
|
|
79
|
+
Do not install permanent hooks from a temporary uvx environment, whose cache can be removed.
|
|
80
|
+
|
|
81
|
+
[PyPI package](https://pypi.org/project/codex-passport-sync/) · [Wheel downloads](https://github.com/JuneLeGency/codex-passport/releases/tag/v0.1.1) · [All installation options and state paths](https://github.com/JuneLeGency/codex-passport/blob/main/docs/PYTHON.md)
|
|
82
|
+
|
|
83
|
+
## Daily controls and scope
|
|
84
|
+
|
|
85
|
+
| Control | Result |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| UP / DOWN | Switch quota windows |
|
|
88
|
+
| Short OK while awake | Mark received notifications read; never approve an action |
|
|
89
|
+
| First press while asleep | Wake only |
|
|
90
|
+
| Hold OK while awake | Reconnect BLE, preserving bonds |
|
|
91
|
+
|
|
92
|
+
The backlight sleeps after 60 seconds without alerts/buttons. Daily BLE frames contain no conversation text.
|
|
93
|
+
An authenticated private relay and paired BLE protect access; bounded phone summaries can still contain private context.
|
|
94
|
+
Keep the relay within your private network/VPN. Do not expose its HTTP port publicly. The computer must stay awake and run the collector.
|
|
95
|
+
|
|
96
|
+
Both BLE routes, route switching, replay and physical controls were validated. Off-LAN WireGuard and battery endurance were not measured.
|
|
97
|
+
See the [validation record](https://github.com/JuneLeGency/codex-passport/blob/main/docs/VALIDATION.md) for the exact evidence and limitations.
|
|
98
|
+
|
|
99
|
+
## Development and attribution
|
|
100
|
+
|
|
101
|
+
[Build instructions](https://github.com/JuneLeGency/codex-passport/blob/main/docs/DEVELOPMENT.md) · [MIT license](https://github.com/JuneLeGency/codex-passport/blob/main/LICENSE) · [Third-party notices](https://github.com/JuneLeGency/codex-passport/blob/main/THIRD_PARTY.md)
|
|
102
|
+
|
|
103
|
+
Application code is independently authored. Official FoloToy BSP, Material Components and other dependencies retain their own licenses.
|
|
104
|
+
The [reference application](https://github.com/zt20/codex-usage-ai-passport) was studied, not copied.
|
|
105
|
+
This is a community project, not an official OpenAI or FoloToy product.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
codex_passport/__init__.py,sha256=sELAPBjn5zoY87T_R0aplM-DNSwBziCaggeddlZjY6I,70
|
|
2
|
+
codex_passport/__main__.py,sha256=z3OQolCKD8rUd3pKYp_FISq2VctFhFl1mV-DVnaoNOM,29
|
|
3
|
+
codex_passport/ble.py,sha256=lEQ23YexDMQAYc4d4tviExTRI65N-EPgefC9gOWLprY,5481
|
|
4
|
+
codex_passport/cli.py,sha256=uwGVhz0a6Wcm2QHG2zgzrHM-Gvnzbdt1gNT439qXFlQ,12087
|
|
5
|
+
codex_passport/relay.py,sha256=ay5rbApvMikCepsNOgowQ_ZmLGq2KkLkXFFVtOYE0Q0,5162
|
|
6
|
+
codex_passport/routing.py,sha256=qNEITmk1fyEHl9JN4U6Lu-hQnJ3iWEogN1F-46heUng,1798
|
|
7
|
+
codex_passport/sessions.py,sha256=yzuk8ucRhpVNRJIHHvNw1uAJ9BnBm9xvHS0C3D1_yE0,7409
|
|
8
|
+
codex_passport/store.py,sha256=osXCPugZeIF_FTSxkL0w2B_fmo1rhSYzbCU8HLVN8To,10325
|
|
9
|
+
codex_passport/usage.py,sha256=mFj0vKKc1XmWUOZGQ61MT-w9u4nkoQthSY2Y0844E0w,3881
|
|
10
|
+
codex_passport_sync-0.1.1.dist-info/METADATA,sha256=INE7yZSRR1xrJnIERwbuXarzu2lSneC3aP51MRBfblQ,6856
|
|
11
|
+
codex_passport_sync-0.1.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
12
|
+
codex_passport_sync-0.1.1.dist-info/entry_points.txt,sha256=VWjP4rZ2kXGmSmpzBGwM3qJlyA8sh01rGeUbGtv6lOg,59
|
|
13
|
+
codex_passport_sync-0.1.1.dist-info/licenses/LICENSE,sha256=S1uVaafz-ejH7BFkY9odpU54ZqEKtIWZce5YDA-G994,1084
|
|
14
|
+
codex_passport_sync-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Codex Passport contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|