From 228e976b0bc1cf01ea6dc5b97bdfa33e48e794e5 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Mon, 26 Oct 2026 09:50:45 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=98=AC=EB=A6=AC=EB=B8=8C=EC=98=81=20?= =?UTF-8?q?=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20=EC=A4=80=EB=B9=84=20?= =?UTF-8?q?=ED=9B=84=20=EC=9E=90=EB=8F=99=20=EC=88=A8=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/macos-ops-runbook.md | 2 +- scripts/relay/hide-oliveyoung.swift | 28 +++++++++++++-- scripts/relay/macos-ops.py | 2 +- tests/scripts/fixtures/macos_ops_tests.py | 15 ++++++++ tests/scripts/hide-readiness.test.ts | 44 +++++++++++++++++++++++ 5 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 tests/scripts/hide-readiness.test.ts diff --git a/docs/macos-ops-runbook.md b/docs/macos-ops-runbook.md index 945b56f2..e9729834 100644 --- a/docs/macos-ops-runbook.md +++ b/docs/macos-ops-runbook.md @@ -27,7 +27,7 @@ launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/page.aka.daiso-op launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/page.aka.daiso-ops-monitor.plist" ``` -업데이트할 때는 새 plist 설치 후 이 두 보조 작업만 `bootout`하고 다시 `bootstrap`한다. 중계·터널 작업의 종료나 재시작은 포함하지 않는다. 숨김 도우미는 비공개 `state/browser-owner.json`의 PID·부모 PID·생성 시각과 실제 프로세스 UID·실행 파일이 모두 맞을 때만 숨긴다. 이미 숨겨진 앱도 소유권을 검증한다. 사용자 Chrome이나 소유권이 불확실한 프로세스에는 요청하지 않는다. +업데이트할 때는 새 plist 설치 후 이 두 보조 작업만 `bootout`하고 다시 `bootstrap`한다. 중계·터널 작업의 종료나 재시작은 포함하지 않는다. 숨김 도우미는 비공개 `state/browser-owner.json`의 PID·부모 PID·생성 시각과 실제 프로세스 UID·실행 파일이 모두 맞을 때만 숨긴다. 이미 숨겨진 앱도 소유권을 검증한다. 새 브라우저 생성 시각 이후의 로컬 감시 기록이 `ready`를 확인한 다음 숨긴다. 초기 브라우저 생성과 숨김이 겹치지 않도록 이전 세션의 기록, 3분 넘게 오래된 기록, 준비 중 또는 실패한 기록은 사용하지 않는다. 감시 기록의 시각은 마지막 저장 시각이 아니라 조회 시작 시각을 사용해, 점검 도중 교체된 새 브라우저에 이전 준비 결과가 적용되지 않게 한다. 로컬 감시는 60초 주기이므로 준비 완료 뒤 숨김까지 최대 한 주기 지연될 수 있다. 사용자 Chrome이나 소유권이 불확실한 프로세스에는 요청하지 않는다. ## 감시와 상태 확인 diff --git a/scripts/relay/hide-oliveyoung.swift b/scripts/relay/hide-oliveyoung.swift index a1d24325..60a4f9ee 100644 --- a/scripts/relay/hide-oliveyoung.swift +++ b/scripts/relay/hide-oliveyoung.swift @@ -8,6 +8,16 @@ func argument(_ name: String, fallback: URL) -> URL { CommandLine.arguments[index + 1].hasPrefix("/") else { exit(2) } return URL(fileURLWithPath: CommandLine.arguments[index + 1]).standardizedFileURL } +// 새 프로세스 준비 전의 이전 스냅샷이나 오래된 상태로 숨기지 않습니다. +func readinessAllowsHiding(_ value: [String: Any], launchedAt: TimeInterval, now: TimeInterval) -> Bool { + guard let checkedAt = value["checkedAt"] as? Double, + checkedAt >= launchedAt + 1, checkedAt <= now + 5, now - checkedAt <= 180, + let checks = value["checks"] as? [String: Any], + let oliveyoung = checks["oliveyoung"] as? [String: Any], + oliveyoung["state"] as? String == "ready", oliveyoung["failed"] as? Bool == false else { return false } + return true +} + let base = argument("--state-base", fallback: FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support/DaisoRelay")) let runtime = argument("--runtime", fallback: base.appendingPathComponent("runtime")) let marker = base.appendingPathComponent("state/browser-owner.json") @@ -22,6 +32,20 @@ func safeMarkerPath() -> Bool { return true } +func readyForHide(_ app: NSRunningApplication) -> Bool { + let status = base.appendingPathComponent("ops-status.json") + guard let launched = app.launchDate, + status.resolvingSymlinksInPath().path == status.path, + let attributes = try? FileManager.default.attributesOfItem(atPath: status.path), + attributes[.type] as? FileAttributeType == .typeRegular, + attributes[.ownerAccountID] as? UInt32 == getuid(), + let mode = attributes[.posixPermissions] as? UInt16, mode & 0o077 == 0, + let size = attributes[.size] as? UInt64, size <= 65536, + let data = try? Data(contentsOf: status), + let value = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return false } + return readinessAllowsHiding(value, launchedAt: launched.timeIntervalSince1970, now: Date().timeIntervalSince1970) +} + // 소유 마커가 바뀌거나 검증에 실패하면 숨김을 생략한다. func target() -> NSRunningApplication? { guard safeMarkerPath(), let attributes = try? FileManager.default.attributesOfItem(atPath: marker.path), @@ -57,13 +81,13 @@ func target() -> NSRunningApplication? { // 매초 확인하되 이미 숨겨진 앱에는 추가 UI 요청을 보내지 않는다. func hideOwnedBrowser() { - guard let app = target(), !app.isHidden else { return } + guard let app = target(), !app.isHidden, readyForHide(app) else { return } _ = app.hide() } if CommandLine.arguments.contains("--check") { if let app = target() { - print("owned=true hidden=\(app.isHidden) pid=\(app.processIdentifier)") + print("owned=true hidden=\(app.isHidden) pid=\(app.processIdentifier) readyToHide=\(readyForHide(app))") } else { print("owned=false") exit(1) diff --git a/scripts/relay/macos-ops.py b/scripts/relay/macos-ops.py index c8bf9c32..dffa6dd4 100644 --- a/scripts/relay/macos-ops.py +++ b/scripts/relay/macos-ops.py @@ -220,6 +220,7 @@ def login_notification(checks, previous, now, enabled): def monitor(base, tunnel_port, notify_local=False): + checked_at = time.time() checks = {} for name, env_file, token_key, port, path in [ ('oliveyoung', 'relay.env', 'OY_RELAY_TOKEN', 4319, '/health'), @@ -248,7 +249,6 @@ def monitor(base, tunnel_port, notify_local=False): checks['tunnel'] = {'failed': True, 'reason': 'readiness-unavailable'} previous_status = snapshot_status(base / 'ops-status.json') previous = previous_status.get('checks', {}) - checked_at = time.time() for name, check in checks.items(): old = previous.get(name, {}) if check['failed']: diff --git a/tests/scripts/fixtures/macos_ops_tests.py b/tests/scripts/fixtures/macos_ops_tests.py index 53440991..ae744f85 100644 --- a/tests/scripts/fixtures/macos_ops_tests.py +++ b/tests/scripts/fixtures/macos_ops_tests.py @@ -108,6 +108,21 @@ def fetch(port,path,token=None): self.assertEqual(second['checks']['oliveyoung']['consecutiveFailures'],2) self.assertEqual(first['checks']['oliveyoung']['firstFailedAt'], second['checks']['oliveyoung']['firstFailedAt']) + def test_monitor_timestamps_observation_before_later_checks(self): + with tempfile.TemporaryDirectory() as root: + base=Path(root).resolve() + for name in ['relay.env', 'dtryx.env']: + (base/name).write_text('OY_RELAY_TOKEN=secret\nDTRYX_RELAY_TOKEN=secret\nCONVENIENCE_RELAY_TOKEN=secret\n') + (base/name).chmod(0o600) + clock=[1000] + def fetch(port,path,token=None): + if path != '/health': clock[0]=1200 + return {'state':'ready'} + with patch.object(ops.time,'time',side_effect=lambda: clock[0]), patch.object(ops,'fetch_health',side_effect=fetch): + result=ops.monitor(base,0) + self.assertEqual(result['checkedAt'],1000) + self.assertEqual(clock[0],1200) + def test_configured_gs_session_requires_observable_authentication(self): with tempfile.TemporaryDirectory() as root: base=Path(root).resolve() diff --git a/tests/scripts/hide-readiness.test.ts b/tests/scripts/hide-readiness.test.ts new file mode 100644 index 00000000..05ce4d70 --- /dev/null +++ b/tests/scripts/hide-readiness.test.ts @@ -0,0 +1,44 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, it } from 'vitest'; +it.skipIf(process.platform !== 'darwin')( + '새 브라우저 생성 이후의 유효한 준비 완료 기록이 있을 때만 숨김을 허용한다', + () => { + const source = readFileSync('scripts/relay/hide-oliveyoung.swift', 'utf8'); + expect(source).toContain('func readinessAllowsHiding'); + const start = source.indexOf('func readinessAllowsHiding'); + const end = source.indexOf('\nlet base =', start); + const implementation = source.slice(start, end); + const ready = { checkedAt: 1100, checks: { oliveyoung: { state: 'ready', failed: false } } }; + const cases = [ + ready, + { ...ready, checkedAt: 999 }, + { ...ready, checkedAt: 1000.5 }, + { ...ready, checkedAt: 1010 }, + { ...ready, checkedAt: 1206 }, + { ...ready, checkedAt: '1100' }, + { ...ready, checks: { oliveyoung: { state: 'starting', failed: false } } }, + { ...ready, checks: { oliveyoung: { state: 'ready', failed: true } } }, + { ...ready, checks: {} }, + {}, + ]; + const directory = mkdtempSync(join(tmpdir(), 'hide-readiness-')); + try { + const script = join(directory, 'main.swift'); + const data = join(directory, 'cases.json'); + writeFileSync(data, JSON.stringify(cases)); + writeFileSync( + script, + 'import Foundation\n' + + implementation + + '\nlet cases=try JSONSerialization.jsonObject(with:Data(contentsOf:URL(fileURLWithPath:CommandLine.arguments[1]))) as! [[String:Any]]\nfor row in cases { print(readinessAllowsHiding(row, launchedAt:1000, now:1200)) }\n', + ); + const output = execFileSync('/usr/bin/swift', [script, data], { encoding: 'utf8' }); + expect(output.trim().split('\n')).toEqual(['true', ...Array(9).fill('false')]); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, +);