sneakoscope 8.2.0 → 8.3.0
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.
- package/README.md +46 -110
- package/crates/sks-core/Cargo.lock +1 -1
- package/crates/sks-core/Cargo.toml +1 -1
- package/dist/cli/command-manifest-lite.js +0 -2
- package/dist/cli/command-registry.js +0 -2
- package/dist/commands/codex.js +3 -3
- package/dist/commands/doctor.js +5 -157
- package/dist/config/skills-manifest.json +1 -1
- package/dist/core/codex/codex-cli-syntax-builder.js +0 -8
- package/dist/core/codex-app/codex-models-cache.js +2 -1
- package/dist/core/codex-app/menubar/constants.js +0 -7
- package/dist/core/codex-compat/codex-capability-matrix.js +7 -7
- package/dist/core/codex-compat/codex-compat-report.js +8 -12
- package/dist/core/codex-compat/codex-runtime-contract.js +62 -0
- package/dist/core/codex-compat/codex-version-policy.js +7 -7
- package/dist/core/codex-control/codex-current-app-capability.js +3 -3
- package/dist/core/codex-control/codex-current-capability.js +67 -94
- package/dist/core/codex-control/codex-current-core-capability.js +3 -3
- package/dist/core/codex-control/codex-current-core-probe-runner.js +4 -4
- package/dist/core/codex-control/codex-current-core-real-probes.js +2 -2
- package/dist/core/codex-control/codex-current-feature-capability.js +4 -4
- package/dist/core/codex-control/codex-current-image-path-real-probe.js +1 -1
- package/dist/core/codex-control/codex-current-web-search-probe.js +1 -1
- package/dist/core/codex-control/codex-sdk-capability.js +2 -2
- package/dist/core/commands/remote-command.js +2 -2
- package/dist/core/context7-client.js +5 -2
- package/dist/core/doctor/codex-current-core-probe-doctor.js +2 -2
- package/dist/core/doctor/doctor-dirty-planner.js +1 -1
- package/dist/core/doctor/doctor-readiness-matrix.js +0 -7
- package/dist/core/feature-fixtures.js +0 -4
- package/dist/core/feature-registry.js +0 -1
- package/dist/core/image-ux-review.js +3 -3
- package/dist/core/install/installed-package-smoke.js +2 -3
- package/dist/core/mad-sks/sql-plane/mcp-executor.js +4 -3
- package/dist/core/mcp/modern-protocol.js +36 -0
- package/dist/core/mcp-config/health-check.js +45 -30
- package/dist/core/perf/release-latency-slo.js +1 -2
- package/dist/core/proof/runtime-truth-matrix.js +1 -1
- package/dist/core/release/canonical-test-proof.js +1 -2
- package/dist/core/release/gate-manifest.js +5 -2
- package/dist/core/release/physical-release-gates.js +0 -26
- package/dist/core/release/release-gate-affected-selector.js +2 -1
- package/dist/core/release/release-gate-contract.js +3 -1
- package/dist/core/release/release-pack-receipt.js +0 -1
- package/dist/core/release/stage-publish.js +1 -1
- package/dist/core/version-manager.js +4 -0
- package/dist/core/version.js +1 -1
- package/dist/native/sks-menubar/Sources/AppDelegate.swift +1 -12
- package/dist/native/sks-menubar/Sources/ControlCenterWindowController.swift +2 -6
- package/dist/native/sks-menubar/Sources/RemoteCodingViewController.swift +28 -471
- package/dist/native/sks-menubar/Sources/SecureProcessEnvelope.swift +1 -43
- package/dist/scripts/codex-current-app-server-v2-check.js +9 -8
- package/dist/scripts/codex-current-binary-identity-check.js +2 -2
- package/dist/scripts/codex-current-capability-check.js +9 -7
- package/dist/scripts/codex-current-dependency-check.js +21 -0
- package/dist/scripts/docs-truthfulness-check.js +4 -19
- package/dist/scripts/fast-codex-service-tier-proof-check.js +0 -1
- package/dist/scripts/install-surface-ssot-check.js +2 -2
- package/dist/scripts/latest-version-guidance-check.js +1 -1
- package/dist/scripts/packlist-performance-check.js +0 -1
- package/dist/scripts/release-metadata-check.js +4 -1
- package/dist/scripts/release-version-truth-check.js +149 -0
- package/dist/vendor/openai-codex/latest/hooks/permission-request.command.input.schema.json +6 -0
- package/dist/vendor/openai-codex/latest/hooks/permission-request.command.output.schema.json +2 -16
- package/dist/vendor/openai-codex/latest/hooks/post-compact.command.input.schema.json +6 -0
- package/dist/vendor/openai-codex/latest/hooks/post-tool-use.command.input.schema.json +6 -0
- package/dist/vendor/openai-codex/latest/hooks/post-tool-use.command.output.schema.json +2 -16
- package/dist/vendor/openai-codex/latest/hooks/pre-compact.command.input.schema.json +6 -0
- package/dist/vendor/openai-codex/latest/hooks/pre-tool-use.command.input.schema.json +6 -0
- package/dist/vendor/openai-codex/latest/hooks/pre-tool-use.command.output.schema.json +2 -16
- package/dist/vendor/openai-codex/latest/hooks/session-start.command.output.schema.json +2 -16
- package/dist/vendor/openai-codex/latest/hooks/snapshot-metadata.json +7 -5
- package/dist/vendor/openai-codex/latest/hooks/subagent-start.command.output.schema.json +4 -18
- package/dist/vendor/openai-codex/latest/hooks/user-prompt-submit.command.input.schema.json +6 -0
- package/dist/vendor/openai-codex/latest/hooks/user-prompt-submit.command.output.schema.json +2 -16
- package/package.json +4 -7
- package/release-gates.v2.json +49 -19
- package/config/codex-releases/rust-v0.146.0.json +0 -45
- package/dist/core/agents/agent-runner-codex-exec.js +0 -177
- package/dist/core/codex-compat/codex-release-manifest.js +0 -72
- package/dist/core/commands/telegram-command.js +0 -447
- package/dist/core/telegram/access.js +0 -58
- package/dist/core/telegram/client.js +0 -157
- package/dist/core/telegram/confirmation.js +0 -37
- package/dist/core/telegram/doctor.js +0 -154
- package/dist/core/telegram/index.js +0 -9
- package/dist/core/telegram/keychain.js +0 -317
- package/dist/core/telegram/liveness.js +0 -124
- package/dist/core/telegram/poller.js +0 -90
- package/dist/core/telegram/redaction.js +0 -15
- package/dist/core/telegram/state-lock.js +0 -416
- package/dist/core/telegram/types.js +0 -1
- package/dist/native/sks-menubar/Sources/TelegramPrivateFileStore.swift +0 -498
- package/dist/native/sks-menubar/Sources/TelegramPrivateFileSupport.swift +0 -236
- package/dist/native/sks-menubar/Sources/TelegramProcessGateway.swift +0 -454
- package/dist/native/sks-menubar/Sources/TelegramRuntimeSupport.swift +0 -56
- package/dist/native/sks-menubar/Sources/TelegramStateLock.swift +0 -448
- package/dist/native/sks-menubar/Sources/TelegramSupport.swift +0 -243
- package/dist/native/sks-menubar/Sources/TelegramTransport.swift +0 -499
- package/dist/scripts/codex-current-manifest-check.js +0 -29
- package/schemas/codex/app-server-0.146/codex_app_server_protocol.v2.schemas.json +0 -19858
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { SYSTEM_TELEGRAM_CLOCK } from './types.js';
|
|
3
|
-
export class TelegramConfirmationStore {
|
|
4
|
-
clock;
|
|
5
|
-
ttlMs;
|
|
6
|
-
pending = new Map();
|
|
7
|
-
constructor(options = {}) {
|
|
8
|
-
this.clock = options.clock ?? SYSTEM_TELEGRAM_CLOCK;
|
|
9
|
-
this.ttlMs = Math.max(15_000, Math.min(options.ttlMs ?? 2 * 60_000, 10 * 60_000));
|
|
10
|
-
}
|
|
11
|
-
issue(command) {
|
|
12
|
-
this.expire();
|
|
13
|
-
const nonce = randomBytes(12).toString('base64url');
|
|
14
|
-
const expiresAt = this.clock.now() + this.ttlMs;
|
|
15
|
-
this.pending.set(nonce, { ...command, input: structuredClone(command.input), nonce, expiresAt, consumed: false });
|
|
16
|
-
return { nonce, expires_at: new Date(expiresAt).toISOString() };
|
|
17
|
-
}
|
|
18
|
-
consume(nonce, chatId, senderId) {
|
|
19
|
-
const item = this.pending.get(nonce);
|
|
20
|
-
if (!item || item.consumed || item.expiresAt <= this.clock.now()) {
|
|
21
|
-
if (item)
|
|
22
|
-
this.pending.delete(nonce);
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
25
|
-
if (item.chatId !== chatId || item.senderId !== senderId)
|
|
26
|
-
return null;
|
|
27
|
-
item.consumed = true;
|
|
28
|
-
this.pending.delete(nonce);
|
|
29
|
-
return { chatId: item.chatId, senderId: item.senderId, command: item.command, input: structuredClone(item.input) };
|
|
30
|
-
}
|
|
31
|
-
expire() {
|
|
32
|
-
for (const [nonce, item] of this.pending) {
|
|
33
|
-
if (item.consumed || item.expiresAt <= this.clock.now())
|
|
34
|
-
this.pending.delete(nonce);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
}
|
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
import { redactTelegramError } from './redaction.js';
|
|
2
|
-
import { readTelegramLivenessReceipt, telegramLivenessPath } from './liveness.js';
|
|
3
|
-
export async function probeTelegram(input) {
|
|
4
|
-
const now = input.now ?? Date.now();
|
|
5
|
-
if (!input.client || !input.tokenProvider) {
|
|
6
|
-
return probeTelegramReceipt(input.receiptPath, now);
|
|
7
|
-
}
|
|
8
|
-
const receiptProbe = !input.access || !input.poller
|
|
9
|
-
? await probeTelegramReceipt(input.receiptPath, now)
|
|
10
|
-
: null;
|
|
11
|
-
const blockers = receiptProbe
|
|
12
|
-
? receiptProbe.blockers.filter((blocker) => ![
|
|
13
|
-
'telegram_token_missing',
|
|
14
|
-
'telegram_getme_not_valid',
|
|
15
|
-
'telegram_getme_stale'
|
|
16
|
-
].includes(blocker))
|
|
17
|
-
: [];
|
|
18
|
-
let tokenConfigured = false;
|
|
19
|
-
try {
|
|
20
|
-
tokenConfigured = Boolean(String(await input.tokenProvider.loadToken() ?? '').trim());
|
|
21
|
-
}
|
|
22
|
-
catch (error) {
|
|
23
|
-
blockers.push(`telegram_token_load_failed:${redactTelegramError(error)}`);
|
|
24
|
-
}
|
|
25
|
-
let identityValid = false;
|
|
26
|
-
let getMeCheckedAt = null;
|
|
27
|
-
let getMeLatencyMs = null;
|
|
28
|
-
if (!tokenConfigured)
|
|
29
|
-
blockers.push('telegram_token_missing');
|
|
30
|
-
else {
|
|
31
|
-
const started = Date.now();
|
|
32
|
-
try {
|
|
33
|
-
identityValid = (await input.client.getMe()).is_bot === true;
|
|
34
|
-
if (!identityValid)
|
|
35
|
-
blockers.push('telegram_getme_not_bot');
|
|
36
|
-
}
|
|
37
|
-
catch (error) {
|
|
38
|
-
blockers.push(`telegram_getme_failed:${redactTelegramError(error)}`);
|
|
39
|
-
}
|
|
40
|
-
finally {
|
|
41
|
-
getMeLatencyMs = Date.now() - started;
|
|
42
|
-
getMeCheckedAt = new Date().toISOString();
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
let pairedChatCount = receiptProbe?.paired_chat_count ?? 0;
|
|
46
|
-
if (input.access) {
|
|
47
|
-
try {
|
|
48
|
-
pairedChatCount = await input.access.authorizedCount();
|
|
49
|
-
}
|
|
50
|
-
catch (error) {
|
|
51
|
-
blockers.push(`telegram_access_state_failed:${redactTelegramError(error)}`);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
if (pairedChatCount === 0 && !blockers.includes('telegram_private_chat_not_paired')) {
|
|
55
|
-
blockers.push('telegram_private_chat_not_paired');
|
|
56
|
-
}
|
|
57
|
-
let poller = receiptProbe?.poller ?? emptyPoller();
|
|
58
|
-
if (input.poller) {
|
|
59
|
-
try {
|
|
60
|
-
poller = input.poller.snapshot();
|
|
61
|
-
}
|
|
62
|
-
catch (error) {
|
|
63
|
-
blockers.push(`telegram_poller_state_failed:${redactTelegramError(error)}`);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
if (!poller.running && !blockers.includes('telegram_poller_not_running')) {
|
|
67
|
-
blockers.push('telegram_poller_not_running');
|
|
68
|
-
}
|
|
69
|
-
return {
|
|
70
|
-
schema: 'sks.telegram-doctor-probe.v1',
|
|
71
|
-
ok: blockers.length === 0,
|
|
72
|
-
status: !tokenConfigured ? 'not_configured' : blockers.length ? 'degraded' : 'ready',
|
|
73
|
-
token_configured: tokenConfigured,
|
|
74
|
-
token_source: tokenConfigured ? (receiptProbe?.token_source ?? 'unknown') : 'none',
|
|
75
|
-
bot_identity_valid: identityValid,
|
|
76
|
-
getme_checked_at: getMeCheckedAt,
|
|
77
|
-
getme_latency_ms: getMeLatencyMs,
|
|
78
|
-
getme_check_kind: 'live',
|
|
79
|
-
paired_chat_count: pairedChatCount,
|
|
80
|
-
audit_healthy: receiptProbe?.audit_healthy ?? true,
|
|
81
|
-
audit_last_error: receiptProbe?.audit_last_error ?? null,
|
|
82
|
-
poller,
|
|
83
|
-
blockers: [...new Set(blockers)],
|
|
84
|
-
checked_at: new Date().toISOString()
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
async function probeTelegramReceipt(receiptPath, now) {
|
|
88
|
-
const liveness = await readTelegramLivenessReceipt(receiptPath ?? telegramLivenessPath(), now);
|
|
89
|
-
if (!liveness.ok) {
|
|
90
|
-
return {
|
|
91
|
-
schema: 'sks.telegram-doctor-probe.v1', ok: false, status: 'not_configured',
|
|
92
|
-
token_configured: false, bot_identity_valid: false, paired_chat_count: 0,
|
|
93
|
-
token_source: 'none',
|
|
94
|
-
audit_healthy: false, audit_last_error: null,
|
|
95
|
-
getme_checked_at: null, getme_latency_ms: null,
|
|
96
|
-
getme_check_kind: 'receipt',
|
|
97
|
-
poller: emptyPoller(), blockers: [liveness.blocker], checked_at: new Date(now).toISOString()
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
const receipt = liveness.receipt;
|
|
101
|
-
const blockers = [];
|
|
102
|
-
if (!receipt.token_configured)
|
|
103
|
-
blockers.push('telegram_token_missing');
|
|
104
|
-
if (receipt.token_configured && !receipt.bot_identity_valid)
|
|
105
|
-
blockers.push('telegram_getme_not_valid');
|
|
106
|
-
const getMeAge = receipt.getme_checked_at ? now - Date.parse(receipt.getme_checked_at) : Number.POSITIVE_INFINITY;
|
|
107
|
-
if (receipt.bot_identity_valid && getMeAge > 10 * 60_000)
|
|
108
|
-
blockers.push('telegram_getme_stale');
|
|
109
|
-
if (receipt.paired_chat_count === 0)
|
|
110
|
-
blockers.push('telegram_private_chat_not_paired');
|
|
111
|
-
if (receipt.audit_healthy === false)
|
|
112
|
-
blockers.push('telegram_audit_unavailable');
|
|
113
|
-
if (!receipt.running || !receipt.poller.running)
|
|
114
|
-
blockers.push('telegram_poller_not_running');
|
|
115
|
-
if (liveness.stale)
|
|
116
|
-
blockers.push('telegram_liveness_stale');
|
|
117
|
-
if (receipt.poller.consecutive_failures > 0)
|
|
118
|
-
blockers.push('telegram_poller_degraded');
|
|
119
|
-
return {
|
|
120
|
-
schema: 'sks.telegram-doctor-probe.v1',
|
|
121
|
-
ok: blockers.length === 0,
|
|
122
|
-
status: !receipt.token_configured ? 'not_configured' : blockers.length ? 'degraded' : 'ready',
|
|
123
|
-
token_configured: receipt.token_configured,
|
|
124
|
-
token_source: receipt.token_source ?? (receipt.token_configured ? 'unknown' : 'none'),
|
|
125
|
-
bot_identity_valid: receipt.bot_identity_valid,
|
|
126
|
-
getme_checked_at: receipt.getme_checked_at ?? null,
|
|
127
|
-
getme_latency_ms: receipt.getme_latency_ms ?? null,
|
|
128
|
-
getme_check_kind: 'receipt',
|
|
129
|
-
paired_chat_count: receipt.paired_chat_count,
|
|
130
|
-
audit_healthy: receipt.audit_healthy !== false,
|
|
131
|
-
audit_last_error: receipt.audit_last_error ?? null,
|
|
132
|
-
poller: receipt.poller,
|
|
133
|
-
blockers,
|
|
134
|
-
checked_at: new Date(now).toISOString()
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
function emptyPoller() {
|
|
138
|
-
return {
|
|
139
|
-
schema: 'sks.telegram-poller-state.v1', running: false, offset: 0,
|
|
140
|
-
consecutive_failures: 0, last_poll_at: null, last_success_at: null,
|
|
141
|
-
last_update_at: null, last_error: null
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
export function telegramSelfHealAction(probe) {
|
|
145
|
-
if (!probe.audit_healthy || probe.blockers.includes('telegram_audit_unavailable'))
|
|
146
|
-
return 'operator_repair_audit';
|
|
147
|
-
if (!probe.token_configured || !probe.bot_identity_valid || probe.blockers.includes('telegram_getme_stale'))
|
|
148
|
-
return 'revalidate_token';
|
|
149
|
-
if (/409|webhook/i.test(probe.poller.last_error ?? ''))
|
|
150
|
-
return 'operator_remove_webhook';
|
|
151
|
-
if (probe.blockers.includes('telegram_liveness_stale') || !probe.poller.running || probe.poller.consecutive_failures > 0)
|
|
152
|
-
return 'restart_poll';
|
|
153
|
-
return 'none';
|
|
154
|
-
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
export * from './access.js';
|
|
2
|
-
export * from './client.js';
|
|
3
|
-
export * from './confirmation.js';
|
|
4
|
-
export * from './doctor.js';
|
|
5
|
-
export * from './liveness.js';
|
|
6
|
-
export * from './keychain.js';
|
|
7
|
-
export * from './poller.js';
|
|
8
|
-
export * from './redaction.js';
|
|
9
|
-
export * from './types.js';
|
|
@@ -1,317 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs/promises';
|
|
2
|
-
import { constants as fsConstants } from 'node:fs';
|
|
3
|
-
import os from 'node:os';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import { randomInt, randomUUID } from 'node:crypto';
|
|
6
|
-
import { assertTestHomeWriteAllowed } from '../fsx.js';
|
|
7
|
-
import { PrivateCredentialFileError, readPrivateCredentialFile, writePrivateTextAtomic } from '../security/private-credential-file.js';
|
|
8
|
-
import { withTelegramStateLock } from './state-lock.js';
|
|
9
|
-
export const TELEGRAM_TOKEN_ENV_NAMES = ['TELEGRAM_BOT_TOKEN', 'SKS_TELEGRAM_BOT_TOKEN'];
|
|
10
|
-
export const TELEGRAM_STATE_SCHEMA = 'sks.telegram-state.v1';
|
|
11
|
-
export const TELEGRAM_PAIRING_SCHEMA = 'sks.telegram-pairing.v1';
|
|
12
|
-
export const TELEGRAM_STATE_MAX_BYTES = 1024 * 1024;
|
|
13
|
-
const TELEGRAM_TOKEN_PATTERN = /^\d{5,20}:[A-Za-z0-9_-]{20,128}$/;
|
|
14
|
-
const TELEGRAM_TOKEN_MAX_BYTES = 1024;
|
|
15
|
-
export function telegramPrivatePaths(env = process.env) {
|
|
16
|
-
const sksHome = path.resolve(env.SKS_HOME || path.join(env.HOME || os.homedir(), '.sneakoscope'));
|
|
17
|
-
const secretDir = path.join(sksHome, 'secrets');
|
|
18
|
-
const stateDir = path.join(sksHome, 'state');
|
|
19
|
-
return {
|
|
20
|
-
sksHome,
|
|
21
|
-
secretDir,
|
|
22
|
-
tokenPath: path.join(secretDir, 'telegram-bot-token'),
|
|
23
|
-
stateDir,
|
|
24
|
-
statePath: path.join(stateDir, 'telegram.json'),
|
|
25
|
-
stateLockPath: path.join(stateDir, '.telegram.lock')
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
export function isValidTelegramBotToken(value) {
|
|
29
|
-
return TELEGRAM_TOKEN_PATTERN.test(String(value ?? '').trim());
|
|
30
|
-
}
|
|
31
|
-
export async function resolveTelegramBotToken(input = {}) {
|
|
32
|
-
const env = input.env || process.env;
|
|
33
|
-
for (const name of TELEGRAM_TOKEN_ENV_NAMES) {
|
|
34
|
-
const token = String(env[name] || '').trim();
|
|
35
|
-
if (token) {
|
|
36
|
-
if (!isValidTelegramBotToken(token))
|
|
37
|
-
throw new Error('telegram_token_invalid');
|
|
38
|
-
return { token, source: 'env', env_var: name };
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
const token = await readStoredTelegramToken(input.paths || telegramPrivatePaths(env));
|
|
42
|
-
return token ? { token, source: 'user_secret_file' } : { token: null, source: null };
|
|
43
|
-
}
|
|
44
|
-
export async function readStoredTelegramToken(paths = telegramPrivatePaths()) {
|
|
45
|
-
if (!await validateExistingPrivateDirectories(paths, 'token'))
|
|
46
|
-
return null;
|
|
47
|
-
try {
|
|
48
|
-
const token = (await readPrivateCredentialFile(paths.sksHome, paths.tokenPath, 'telegram_bot_token', { maxBytes: TELEGRAM_TOKEN_MAX_BYTES })).bytes.toString('utf8').trim();
|
|
49
|
-
if (!token)
|
|
50
|
-
return null;
|
|
51
|
-
if (!isValidTelegramBotToken(token))
|
|
52
|
-
throw new Error('telegram_token_invalid');
|
|
53
|
-
return token;
|
|
54
|
-
}
|
|
55
|
-
catch (error) {
|
|
56
|
-
if (error instanceof PrivateCredentialFileError && error.code === 'missing')
|
|
57
|
-
return null;
|
|
58
|
-
throw error;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
export async function storeTelegramToken(tokenInput, options = {}) {
|
|
62
|
-
const token = String(tokenInput ?? '').trim();
|
|
63
|
-
if (!isValidTelegramBotToken(token))
|
|
64
|
-
throw new Error('telegram_token_invalid');
|
|
65
|
-
const paths = resolvePrivatePaths(options);
|
|
66
|
-
await ensureTelegramPrivateDirectories(paths, 'token');
|
|
67
|
-
await writePrivateTextAtomic(paths.sksHome, paths.tokenPath, `${token}\n`, 'telegram_bot_token');
|
|
68
|
-
}
|
|
69
|
-
export async function preflightTelegramTokenStorage(tokenInput, options = {}) {
|
|
70
|
-
const token = String(tokenInput ?? '').trim();
|
|
71
|
-
if (!isValidTelegramBotToken(token))
|
|
72
|
-
throw new Error('telegram_token_invalid');
|
|
73
|
-
const paths = resolvePrivatePaths(options);
|
|
74
|
-
await ensureTelegramPrivateDirectories(paths, 'token');
|
|
75
|
-
try {
|
|
76
|
-
await readPrivateCredentialFile(paths.sksHome, paths.tokenPath, 'telegram_bot_token', { maxBytes: TELEGRAM_TOKEN_MAX_BYTES });
|
|
77
|
-
}
|
|
78
|
-
catch (error) {
|
|
79
|
-
if (!(error instanceof PrivateCredentialFileError && error.code === 'missing'))
|
|
80
|
-
throw error;
|
|
81
|
-
}
|
|
82
|
-
const probePath = path.join(paths.secretDir, `.telegram-token-preflight.${randomUUID()}`);
|
|
83
|
-
assertTestHomeWriteAllowed(probePath);
|
|
84
|
-
let handle = null;
|
|
85
|
-
let identity = null;
|
|
86
|
-
try {
|
|
87
|
-
handle = await fs.open(probePath, 'wx', 0o600);
|
|
88
|
-
const stat = await handle.stat();
|
|
89
|
-
if (!stat.isFile() || (stat.mode & 0o777) !== 0o600)
|
|
90
|
-
throw new Error('telegram_token_preflight_unsafe_file');
|
|
91
|
-
const expectedUid = typeof process.getuid === 'function' ? process.getuid() : null;
|
|
92
|
-
if (expectedUid !== null && stat.uid !== expectedUid)
|
|
93
|
-
throw new Error('telegram_token_preflight_owner_mismatch');
|
|
94
|
-
identity = { dev: stat.dev, ino: stat.ino };
|
|
95
|
-
await handle.writeFile(`${'x'.repeat(Buffer.byteLength(token, 'utf8'))}\n`, 'utf8');
|
|
96
|
-
await handle.sync();
|
|
97
|
-
}
|
|
98
|
-
finally {
|
|
99
|
-
await handle?.close().catch(() => undefined);
|
|
100
|
-
const current = await fs.lstat(probePath).catch(() => null);
|
|
101
|
-
if (identity && current?.isFile() && !current.isSymbolicLink()
|
|
102
|
-
&& current.dev === identity.dev && current.ino === identity.ino) {
|
|
103
|
-
await fs.unlink(probePath);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
export async function bindTelegramBotIdentity(botId, options = {}) {
|
|
108
|
-
if (!Number.isSafeInteger(botId) || botId <= 0)
|
|
109
|
-
throw new Error('telegram_bot_id_invalid');
|
|
110
|
-
let result = null;
|
|
111
|
-
await updateTelegramState((state) => {
|
|
112
|
-
const previousBotId = state.bot_id;
|
|
113
|
-
const rotated = previousBotId !== botId;
|
|
114
|
-
const legacyOrPendingChat = state.chats.length > 1 || state.chats.some((chat) => chat.active === false);
|
|
115
|
-
const stateReset = rotated || legacyOrPendingChat;
|
|
116
|
-
result = {
|
|
117
|
-
bot_id: botId,
|
|
118
|
-
previous_bot_id: previousBotId,
|
|
119
|
-
rotated,
|
|
120
|
-
state_reset: stateReset
|
|
121
|
-
};
|
|
122
|
-
return {
|
|
123
|
-
...state,
|
|
124
|
-
bot_id: botId,
|
|
125
|
-
...(stateReset ? {
|
|
126
|
-
poll_offset: 0,
|
|
127
|
-
pairing: null,
|
|
128
|
-
chats: [],
|
|
129
|
-
confirmations: []
|
|
130
|
-
} : {})
|
|
131
|
-
};
|
|
132
|
-
}, resolvePrivatePaths(options));
|
|
133
|
-
if (!result)
|
|
134
|
-
throw new Error('telegram_bot_binding_failed');
|
|
135
|
-
return result;
|
|
136
|
-
}
|
|
137
|
-
export function emptyTelegramState() {
|
|
138
|
-
return {
|
|
139
|
-
schema: TELEGRAM_STATE_SCHEMA,
|
|
140
|
-
bot_id: null,
|
|
141
|
-
poll_offset: 0,
|
|
142
|
-
pairing: null,
|
|
143
|
-
chats: [],
|
|
144
|
-
confirmations: []
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
export async function readTelegramState(paths = telegramPrivatePaths()) {
|
|
148
|
-
if (!await validateExistingPrivateDirectories(paths, 'state'))
|
|
149
|
-
return emptyTelegramState();
|
|
150
|
-
let bytes;
|
|
151
|
-
try {
|
|
152
|
-
bytes = (await readPrivateCredentialFile(paths.sksHome, paths.statePath, 'telegram_state', { maxBytes: TELEGRAM_STATE_MAX_BYTES })).bytes;
|
|
153
|
-
}
|
|
154
|
-
catch (error) {
|
|
155
|
-
if (error instanceof PrivateCredentialFileError && error.code === 'missing')
|
|
156
|
-
return emptyTelegramState();
|
|
157
|
-
throw error;
|
|
158
|
-
}
|
|
159
|
-
if (bytes.length > TELEGRAM_STATE_MAX_BYTES)
|
|
160
|
-
throw new Error('telegram_state_too_large');
|
|
161
|
-
let parsed;
|
|
162
|
-
try {
|
|
163
|
-
parsed = JSON.parse(bytes.toString('utf8'));
|
|
164
|
-
}
|
|
165
|
-
catch {
|
|
166
|
-
throw new Error('telegram_state_invalid');
|
|
167
|
-
}
|
|
168
|
-
if (!isTelegramState(parsed))
|
|
169
|
-
throw new Error('telegram_state_invalid');
|
|
170
|
-
return normalizeTelegramState(parsed);
|
|
171
|
-
}
|
|
172
|
-
export async function writeTelegramState(state, paths = telegramPrivatePaths()) {
|
|
173
|
-
if (!isTelegramState(state))
|
|
174
|
-
throw new Error('telegram_state_invalid');
|
|
175
|
-
const normalized = normalizeTelegramState(state);
|
|
176
|
-
await ensureTelegramPrivateDirectories(paths, 'state');
|
|
177
|
-
const serialized = `${JSON.stringify(normalized, null, 2)}\n`;
|
|
178
|
-
if (Buffer.byteLength(serialized, 'utf8') > TELEGRAM_STATE_MAX_BYTES) {
|
|
179
|
-
throw new Error('telegram_state_too_large');
|
|
180
|
-
}
|
|
181
|
-
await writePrivateTextAtomic(paths.sksHome, paths.statePath, serialized, 'telegram_state');
|
|
182
|
-
}
|
|
183
|
-
export async function updateTelegramState(update, paths = telegramPrivatePaths()) {
|
|
184
|
-
await ensureTelegramPrivateDirectories(paths, 'state');
|
|
185
|
-
return withTelegramStateLock(paths, async () => {
|
|
186
|
-
const next = await update(await readTelegramState(paths));
|
|
187
|
-
await writeTelegramState(next, paths);
|
|
188
|
-
return next;
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
export async function issueTelegramPairingCode(options = {}) {
|
|
192
|
-
const now = options.now ?? Date.now();
|
|
193
|
-
const ttlMs = Math.max(30_000, Math.min(options.ttlMs ?? 5 * 60_000, 15 * 60_000));
|
|
194
|
-
const result = {
|
|
195
|
-
code: `${randomInt(100_000, 1_000_000)}-${randomUUID().slice(0, 4).toUpperCase()}`,
|
|
196
|
-
expires_at: new Date(now + ttlMs).toISOString()
|
|
197
|
-
};
|
|
198
|
-
await updateTelegramState((state) => ({
|
|
199
|
-
...state,
|
|
200
|
-
...(state.chats.length > 1 ? {
|
|
201
|
-
poll_offset: 0,
|
|
202
|
-
chats: [],
|
|
203
|
-
confirmations: []
|
|
204
|
-
} : {}),
|
|
205
|
-
pairing: {
|
|
206
|
-
schema: TELEGRAM_PAIRING_SCHEMA,
|
|
207
|
-
...result,
|
|
208
|
-
used: false
|
|
209
|
-
}
|
|
210
|
-
}), resolvePrivatePaths(options));
|
|
211
|
-
return result;
|
|
212
|
-
}
|
|
213
|
-
async function ensureTelegramPrivateDirectories(paths, target) {
|
|
214
|
-
await ensurePrivateDirectory(paths.sksHome, 'telegram_sks_home', false);
|
|
215
|
-
await ensurePrivateDirectory(target === 'token' ? paths.secretDir : paths.stateDir, `telegram_${target}_directory`);
|
|
216
|
-
}
|
|
217
|
-
async function validateExistingPrivateDirectories(paths, target) {
|
|
218
|
-
if (!await pathExists(paths.sksHome))
|
|
219
|
-
return false;
|
|
220
|
-
await inspectPrivateDirectory(paths.sksHome, 'telegram_sks_home', false);
|
|
221
|
-
const child = target === 'token' ? paths.secretDir : paths.stateDir;
|
|
222
|
-
if (!await pathExists(child))
|
|
223
|
-
return false;
|
|
224
|
-
await inspectPrivateDirectory(child, `telegram_${target}_directory`);
|
|
225
|
-
return true;
|
|
226
|
-
}
|
|
227
|
-
async function ensurePrivateDirectory(directory, label, requireMode0700 = true) {
|
|
228
|
-
assertTestHomeWriteAllowed(directory);
|
|
229
|
-
try {
|
|
230
|
-
await fs.mkdir(directory, { mode: 0o700 });
|
|
231
|
-
}
|
|
232
|
-
catch (error) {
|
|
233
|
-
if (error?.code !== 'EEXIST')
|
|
234
|
-
throw error;
|
|
235
|
-
}
|
|
236
|
-
await inspectPrivateDirectory(directory, label, requireMode0700);
|
|
237
|
-
}
|
|
238
|
-
async function inspectPrivateDirectory(directory, label, requireMode0700 = true) {
|
|
239
|
-
const stat = await fs.lstat(directory);
|
|
240
|
-
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
241
|
-
throw new Error(`${label}_unsafe_type`);
|
|
242
|
-
if (requireMode0700 && (stat.mode & 0o777) !== 0o700)
|
|
243
|
-
throw new Error(`${label}_mode_not_0700`);
|
|
244
|
-
if (!requireMode0700 && (stat.mode & 0o022) !== 0)
|
|
245
|
-
throw new Error(`${label}_group_or_other_writable`);
|
|
246
|
-
const expectedUid = typeof process.getuid === 'function' ? process.getuid() : null;
|
|
247
|
-
if (expectedUid !== null && stat.uid !== expectedUid)
|
|
248
|
-
throw new Error(`${label}_owner_mismatch`);
|
|
249
|
-
return stat;
|
|
250
|
-
}
|
|
251
|
-
async function pathExists(file) {
|
|
252
|
-
try {
|
|
253
|
-
await fs.lstat(file);
|
|
254
|
-
return true;
|
|
255
|
-
}
|
|
256
|
-
catch (error) {
|
|
257
|
-
if (error?.code === 'ENOENT')
|
|
258
|
-
return false;
|
|
259
|
-
throw error;
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
function resolvePrivatePaths(options) {
|
|
263
|
-
if (options.paths)
|
|
264
|
-
return options.paths;
|
|
265
|
-
if (!options.home)
|
|
266
|
-
return telegramPrivatePaths(options.env || process.env);
|
|
267
|
-
return telegramPrivatePaths({ ...(options.env || process.env), SKS_HOME: undefined, HOME: options.home });
|
|
268
|
-
}
|
|
269
|
-
function isTelegramState(value) {
|
|
270
|
-
if (!isRecord(value)
|
|
271
|
-
|| value.schema !== TELEGRAM_STATE_SCHEMA
|
|
272
|
-
|| !Array.isArray(value.chats)
|
|
273
|
-
|| !Array.isArray(value.confirmations))
|
|
274
|
-
return false;
|
|
275
|
-
if (value.bot_id !== undefined && value.bot_id !== null
|
|
276
|
-
&& (!Number.isSafeInteger(value.bot_id) || Number(value.bot_id) <= 0))
|
|
277
|
-
return false;
|
|
278
|
-
if (value.poll_offset !== undefined
|
|
279
|
-
&& (!Number.isSafeInteger(value.poll_offset) || Number(value.poll_offset) < 0))
|
|
280
|
-
return false;
|
|
281
|
-
if (value.pairing !== null && !isPairing(value.pairing))
|
|
282
|
-
return false;
|
|
283
|
-
return value.chats.every((chat) => isRecord(chat)
|
|
284
|
-
&& Number.isSafeInteger(chat.chat_id)
|
|
285
|
-
&& Number(chat.chat_id) > 0
|
|
286
|
-
&& Number.isSafeInteger(chat.sender_id)
|
|
287
|
-
&& Number(chat.sender_id) > 0
|
|
288
|
-
&& typeof chat.paired_at === 'string'
|
|
289
|
-
&& (chat.active === undefined || typeof chat.active === 'boolean'))
|
|
290
|
-
&& value.confirmations.every((confirmation) => isRecord(confirmation)
|
|
291
|
-
&& typeof confirmation.nonce === 'string'
|
|
292
|
-
&& Number.isSafeInteger(confirmation.chat_id)
|
|
293
|
-
&& Number(confirmation.chat_id) > 0
|
|
294
|
-
&& Number.isSafeInteger(confirmation.sender_id)
|
|
295
|
-
&& Number(confirmation.sender_id) > 0
|
|
296
|
-
&& typeof confirmation.command === 'string'
|
|
297
|
-
&& typeof confirmation.input_json === 'string'
|
|
298
|
-
&& typeof confirmation.expires_at === 'string');
|
|
299
|
-
}
|
|
300
|
-
function normalizeTelegramState(value) {
|
|
301
|
-
return {
|
|
302
|
-
...value,
|
|
303
|
-
bot_id: value.bot_id ?? null,
|
|
304
|
-
poll_offset: value.poll_offset ?? 0,
|
|
305
|
-
chats: value.chats.map((chat) => ({ ...chat, active: chat.active ?? true }))
|
|
306
|
-
};
|
|
307
|
-
}
|
|
308
|
-
function isPairing(value) {
|
|
309
|
-
return isRecord(value)
|
|
310
|
-
&& value.schema === TELEGRAM_PAIRING_SCHEMA
|
|
311
|
-
&& typeof value.code === 'string'
|
|
312
|
-
&& typeof value.expires_at === 'string'
|
|
313
|
-
&& typeof value.used === 'boolean';
|
|
314
|
-
}
|
|
315
|
-
function isRecord(value) {
|
|
316
|
-
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
317
|
-
}
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs/promises';
|
|
2
|
-
import os from 'node:os';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { randomUUID } from 'node:crypto';
|
|
5
|
-
const MAX_RECEIPT_BYTES = 64 * 1024;
|
|
6
|
-
export function telegramLivenessPath(home = process.env.HOME || os.homedir()) {
|
|
7
|
-
return path.join(path.resolve(home), '.codex', 'sks-menubar', 'telegram-liveness.json');
|
|
8
|
-
}
|
|
9
|
-
export function createTelegramLivenessReceipt(input) {
|
|
10
|
-
if (!optionalPositiveSafeInteger(input.botId))
|
|
11
|
-
throw new Error('telegram_liveness_bot_id_invalid');
|
|
12
|
-
return {
|
|
13
|
-
schema: 'sks.telegram-liveness.v1',
|
|
14
|
-
generation: input.generation,
|
|
15
|
-
pid: input.pid ?? process.pid,
|
|
16
|
-
running: input.running,
|
|
17
|
-
token_configured: input.tokenConfigured,
|
|
18
|
-
token_source: input.tokenSource ?? (input.tokenConfigured ? 'unknown' : 'none'),
|
|
19
|
-
bot_id: input.botId ?? null,
|
|
20
|
-
bot_identity_valid: input.botIdentityValid,
|
|
21
|
-
getme_checked_at: input.getMeCheckedAt ?? (input.botIdentityValid ? (input.heartbeatAt ?? new Date().toISOString()) : null),
|
|
22
|
-
getme_latency_ms: input.getMeLatencyMs ?? (input.botIdentityValid ? 0 : null),
|
|
23
|
-
paired_chat_count: Math.max(0, Math.floor(input.pairedChatCount)),
|
|
24
|
-
started_at: input.startedAt,
|
|
25
|
-
heartbeat_at: input.heartbeatAt ?? new Date().toISOString(),
|
|
26
|
-
stale_after_seconds: Math.max(15, Math.min(input.staleAfterSeconds ?? 120, 600)),
|
|
27
|
-
audit_healthy: input.auditHealthy ?? true,
|
|
28
|
-
audit_last_error: input.auditLastError ?? null,
|
|
29
|
-
poller: { ...input.poller }
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
export async function writeTelegramLivenessReceipt(file, receipt) {
|
|
33
|
-
const target = path.resolve(file);
|
|
34
|
-
const directory = path.dirname(target);
|
|
35
|
-
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
36
|
-
const temporary = path.join(directory, `.${path.basename(target)}.${randomUUID()}.tmp`);
|
|
37
|
-
try {
|
|
38
|
-
await fs.writeFile(temporary, `${JSON.stringify(receipt, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
39
|
-
await fs.rename(temporary, target);
|
|
40
|
-
await fs.chmod(target, 0o600);
|
|
41
|
-
}
|
|
42
|
-
finally {
|
|
43
|
-
await fs.rm(temporary, { force: true }).catch(() => undefined);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
export async function readTelegramLivenessReceipt(file = telegramLivenessPath(), now = Date.now()) {
|
|
47
|
-
const target = path.resolve(file);
|
|
48
|
-
const stat = await fs.lstat(target).catch(() => null);
|
|
49
|
-
if (!stat)
|
|
50
|
-
return { ok: false, receipt: null, blocker: 'telegram_liveness_missing' };
|
|
51
|
-
if (!stat.isFile() || stat.isSymbolicLink())
|
|
52
|
-
return { ok: false, receipt: null, blocker: 'telegram_liveness_unsafe_type' };
|
|
53
|
-
if (stat.size <= 0 || stat.size > MAX_RECEIPT_BYTES)
|
|
54
|
-
return { ok: false, receipt: null, blocker: 'telegram_liveness_invalid_size' };
|
|
55
|
-
if (process.platform !== 'win32' && (stat.mode & 0o077) !== 0) {
|
|
56
|
-
return { ok: false, receipt: null, blocker: 'telegram_liveness_insecure_mode' };
|
|
57
|
-
}
|
|
58
|
-
if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
|
|
59
|
-
return { ok: false, receipt: null, blocker: 'telegram_liveness_wrong_owner' };
|
|
60
|
-
}
|
|
61
|
-
let parsed;
|
|
62
|
-
try {
|
|
63
|
-
parsed = JSON.parse(await fs.readFile(target, 'utf8'));
|
|
64
|
-
}
|
|
65
|
-
catch {
|
|
66
|
-
return { ok: false, receipt: null, blocker: 'telegram_liveness_invalid_json' };
|
|
67
|
-
}
|
|
68
|
-
if (!isTelegramLivenessReceipt(parsed)) {
|
|
69
|
-
return { ok: false, receipt: null, blocker: 'telegram_liveness_invalid_schema' };
|
|
70
|
-
}
|
|
71
|
-
const heartbeat = Date.parse(parsed.heartbeat_at);
|
|
72
|
-
const ageMs = Number.isFinite(heartbeat) ? Math.max(0, now - heartbeat) : Number.POSITIVE_INFINITY;
|
|
73
|
-
return {
|
|
74
|
-
ok: true,
|
|
75
|
-
receipt: parsed,
|
|
76
|
-
stale: ageMs > parsed.stale_after_seconds * 1_000,
|
|
77
|
-
age_ms: ageMs
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
function isTelegramLivenessReceipt(value) {
|
|
81
|
-
if (!value || typeof value !== 'object')
|
|
82
|
-
return false;
|
|
83
|
-
const row = value;
|
|
84
|
-
return row.schema === 'sks.telegram-liveness.v1'
|
|
85
|
-
&& typeof row.generation === 'string' && row.generation.length >= 8 && row.generation.length <= 128
|
|
86
|
-
&& Number.isSafeInteger(row.pid) && Number(row.pid) > 0
|
|
87
|
-
&& typeof row.running === 'boolean'
|
|
88
|
-
&& typeof row.token_configured === 'boolean'
|
|
89
|
-
&& (row.token_source === undefined || ['env', 'user_secret_file', 'none', 'unknown'].includes(String(row.token_source)))
|
|
90
|
-
&& optionalPositiveSafeInteger(row.bot_id)
|
|
91
|
-
&& typeof row.bot_identity_valid === 'boolean'
|
|
92
|
-
&& optionalNullableIso(row.getme_checked_at)
|
|
93
|
-
&& (row.getme_latency_ms === undefined || row.getme_latency_ms === null
|
|
94
|
-
|| (Number.isFinite(row.getme_latency_ms) && Number(row.getme_latency_ms) >= 0))
|
|
95
|
-
&& Number.isSafeInteger(row.paired_chat_count) && Number(row.paired_chat_count) >= 0
|
|
96
|
-
&& typeof row.started_at === 'string' && Number.isFinite(Date.parse(row.started_at))
|
|
97
|
-
&& typeof row.heartbeat_at === 'string' && Number.isFinite(Date.parse(row.heartbeat_at))
|
|
98
|
-
&& Number.isFinite(row.stale_after_seconds) && Number(row.stale_after_seconds) >= 15
|
|
99
|
-
&& (row.audit_healthy === undefined || typeof row.audit_healthy === 'boolean')
|
|
100
|
-
&& (row.audit_last_error === undefined || row.audit_last_error === null || typeof row.audit_last_error === 'string')
|
|
101
|
-
&& isPollSnapshot(row.poller);
|
|
102
|
-
}
|
|
103
|
-
function optionalPositiveSafeInteger(value) {
|
|
104
|
-
return value === undefined || value === null || (Number.isSafeInteger(value) && Number(value) > 0);
|
|
105
|
-
}
|
|
106
|
-
function isPollSnapshot(value) {
|
|
107
|
-
if (!value || typeof value !== 'object')
|
|
108
|
-
return false;
|
|
109
|
-
const row = value;
|
|
110
|
-
return row.schema === 'sks.telegram-poller-state.v1'
|
|
111
|
-
&& typeof row.running === 'boolean'
|
|
112
|
-
&& Number.isSafeInteger(row.offset) && Number(row.offset) >= 0
|
|
113
|
-
&& Number.isSafeInteger(row.consecutive_failures) && Number(row.consecutive_failures) >= 0
|
|
114
|
-
&& optionalNullableIso(row.last_poll_at)
|
|
115
|
-
&& optionalNullableIso(row.last_success_at)
|
|
116
|
-
&& optionalNullableIso(row.last_update_at)
|
|
117
|
-
&& (row.last_error === undefined || row.last_error === null || typeof row.last_error === 'string');
|
|
118
|
-
}
|
|
119
|
-
function nullableIso(value) {
|
|
120
|
-
return value === null || (typeof value === 'string' && Number.isFinite(Date.parse(value)));
|
|
121
|
-
}
|
|
122
|
-
function optionalNullableIso(value) {
|
|
123
|
-
return value === undefined || nullableIso(value);
|
|
124
|
-
}
|