@rikaidev/yomi 0.3.1 → 0.4.1
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/PRIVACY.md +14 -0
- package/README.md +3 -0
- package/dist/auth/credential-store.js +11 -1
- package/dist/mcp/client-capabilities.js +55 -0
- package/dist/mcp/handlers/login-copy.js +200 -0
- package/dist/mcp/handlers/login-mrtr.js +150 -0
- package/dist/mcp/handlers/login-session.js +132 -0
- package/dist/mcp/handlers/login.js +67 -239
- package/dist/mcp/server.js +191 -229
- package/dist/mcp/tools.js +3 -14
- package/dist/mcp/ui/resource.js +3 -3
- package/dist/mcp/ui/tools-with-ui.js +3 -4
- package/dist/util/update-check.js +121 -0
- package/dist/version.js +1 -1
- package/package.json +3 -2
package/PRIVACY.md
CHANGED
|
@@ -19,6 +19,20 @@ The only data that leaves your device is whatever the agent surfaces in its
|
|
|
19
19
|
replies to you — and in a cloud assistant, that surfaced text is processed by
|
|
20
20
|
that assistant's model. Yomi itself makes no outbound copy of your messages.
|
|
21
21
|
|
|
22
|
+
## The one non-LINE request Yomi makes
|
|
23
|
+
|
|
24
|
+
At startup Yomi asks the public npm registry whether a newer version has been
|
|
25
|
+
published, and tells the agent if so. This exists because a Desktop Extension
|
|
26
|
+
(`.mcpb`) installed by hand never auto-updates — without the check you could
|
|
27
|
+
sit on an old build, security fixes included, and never know.
|
|
28
|
+
|
|
29
|
+
It sends **no message content and no account identifier**: it is a plain GET of
|
|
30
|
+
a public package document. What it does reveal to the registry is that your IP
|
|
31
|
+
address is running Yomi, and roughly which version. Nothing is uploaded.
|
|
32
|
+
|
|
33
|
+
Set **`YOMI_NO_UPDATE_CHECK=1`** to turn it off; Yomi then makes no outbound
|
|
34
|
+
request except to LINE itself.
|
|
35
|
+
|
|
22
36
|
## Excluding conversations
|
|
23
37
|
|
|
24
38
|
You can exclude any conversation at any time, in plain language to the agent
|
package/README.md
CHANGED
|
@@ -36,6 +36,9 @@ reply, who ties your world together, and what has gone overdue.
|
|
|
36
36
|
No official API, no bot account, no webhook. Yomi logs in as a secondary device
|
|
37
37
|
on your own account.
|
|
38
38
|
|
|
39
|
+
Guides: **[What is a LINE MCP server?](https://rikaidev.github.io/yomi/line-mcp/)**
|
|
40
|
+
· **[LINE MCP 是什麼?Yomi、官方 Bot 與桌面自動化的差異](https://rikaidev.github.io/yomi/zh-tw/line-mcp/)**.
|
|
41
|
+
|
|
39
42
|
   
|
|
40
43
|
|
|
41
44
|
> **Unofficial.** Yomi is an independent personal project, not affiliated with or
|
|
@@ -16,6 +16,15 @@
|
|
|
16
16
|
* KeychainService service name: see keychain.ts — writes go to Yomi's own
|
|
17
17
|
* canonical namespace; a legacy namespace from before this split is read
|
|
18
18
|
* as a fallback and migrated forward, never written to or deleted.
|
|
19
|
+
*
|
|
20
|
+
* The Keychain is machine-global: it is NOT scoped by YOMI_DATA_DIR, so on
|
|
21
|
+
* macOS an automated run that only redirected the data dir would still find —
|
|
22
|
+
* and act on — the developer's real LINE session (a stored login certificate
|
|
23
|
+
* is enough for `login` to start a device sign-in against the live account).
|
|
24
|
+
* `YOMI_NO_KEYCHAIN=1` forces the file backend so tests and smoke runs are
|
|
25
|
+
* genuinely isolated by pointing YOMI_DATA_DIR somewhere disposable. It is a
|
|
26
|
+
* testing escape hatch: setting it in normal use downgrades credential
|
|
27
|
+
* storage from the Keychain to a plain JSON file.
|
|
19
28
|
*/
|
|
20
29
|
import { dirname, join } from 'node:path';
|
|
21
30
|
import { getKeychainService } from './keychain.js';
|
|
@@ -188,7 +197,8 @@ export class CredentialStore {
|
|
|
188
197
|
this.filePath = fallbackFilePath;
|
|
189
198
|
this.cache = new Map();
|
|
190
199
|
this.loaded = false;
|
|
191
|
-
this.secureStorageEnabled =
|
|
200
|
+
this.secureStorageEnabled =
|
|
201
|
+
process.platform === 'darwin' && process.env.YOMI_NO_KEYCHAIN !== '1';
|
|
192
202
|
this.keychainService = this.secureStorageEnabled
|
|
193
203
|
? getKeychainService()
|
|
194
204
|
: null;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { CLIENT_CAPABILITIES_META_KEY } from '@modelcontextprotocol/server';
|
|
2
|
+
/**
|
|
3
|
+
* The capabilities the CURRENT request's client declared.
|
|
4
|
+
*
|
|
5
|
+
* @param server - MCP Server instance (the legacy, handshake-scoped source).
|
|
6
|
+
* @param ctx - Request handler context (the modern, per-request source).
|
|
7
|
+
* @returns The declared capabilities, or undefined when the client declared
|
|
8
|
+
* none and no envelope carried any.
|
|
9
|
+
*/
|
|
10
|
+
export function clientCapabilitiesOf(server, ctx) {
|
|
11
|
+
// The envelope type is opaque (`RequestMetaEnvelope = {}`) — the SDK models
|
|
12
|
+
// it as a bag of reserved keys, so reaching the declared capabilities off it
|
|
13
|
+
// needs an index-signature view rather than a property access.
|
|
14
|
+
const envelope = ctx?.mcpReq.envelope;
|
|
15
|
+
return (envelope?.[CLIENT_CAPABILITIES_META_KEY] ?? server.getClientCapabilities());
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Does the connected client accept form-mode elicitation (`elicitation/create`
|
|
19
|
+
* carrying a `requestedSchema`)?
|
|
20
|
+
*
|
|
21
|
+
* @param capabilities - Result of `server.getClientCapabilities()`.
|
|
22
|
+
* @returns True when the client declared `elicitation.form`, or declared a
|
|
23
|
+
* bare `elicitation` with no mode sub-keys at all (pre-2026-07-28 shape).
|
|
24
|
+
*/
|
|
25
|
+
export function supportsFormElicitation(capabilities) {
|
|
26
|
+
const elicitation = capabilities?.elicitation;
|
|
27
|
+
if (!elicitation || typeof elicitation !== 'object') {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
if ('form' in elicitation && elicitation.form) {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
// A client that named `url` and not `form` is url-only; one that named
|
|
34
|
+
// neither predates the split and can only have meant form.
|
|
35
|
+
return !('url' in elicitation && elicitation.url);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Does the connected client accept url-mode elicitation (`elicitation/create`
|
|
39
|
+
* with `mode: "url"`, answered out-of-band and closed by a
|
|
40
|
+
* `notifications/elicitation/complete`)?
|
|
41
|
+
*
|
|
42
|
+
* Not used by any flow yet — url mode has no observed client support, the
|
|
43
|
+
* same trap ./ui/login-app.ts documents for MCP Apps. Exported so a future
|
|
44
|
+
* url-mode path can gate on the real declaration rather than assume it.
|
|
45
|
+
*
|
|
46
|
+
* @param capabilities - Result of `server.getClientCapabilities()`.
|
|
47
|
+
* @returns True only when the client explicitly declared `elicitation.url`.
|
|
48
|
+
*/
|
|
49
|
+
export function supportsUrlElicitation(capabilities) {
|
|
50
|
+
const elicitation = capabilities?.elicitation;
|
|
51
|
+
if (!elicitation || typeof elicitation !== 'object') {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return 'url' in elicitation && Boolean(elicitation.url);
|
|
55
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { LINE_PIN_CODE_LIFETIME_MS } from '../../line/auth/pwless/index.js';
|
|
2
|
+
/** LINE region codes offered on the login form. */
|
|
3
|
+
export const LOGIN_REGIONS = ['TW', 'JP', 'TH', 'ID', 'US'];
|
|
4
|
+
/** Where the toggle lives on the primary phone, in LINE's own words. */
|
|
5
|
+
export const PRIMARY_DEVICE_SETTING_PATH = '設定 > 我的帳號 > 允許自其他裝置登入';
|
|
6
|
+
/**
|
|
7
|
+
* The prerequisite as a single line, for texts that report on a login
|
|
8
|
+
* already in flight (the PIN and cert steps) rather than gating one.
|
|
9
|
+
*/
|
|
10
|
+
const PRIMARY_DEVICE_SETTING_NOTE = `0. This only works if the primary phone has ${PRIMARY_DEVICE_SETTING_PATH} ` +
|
|
11
|
+
'(Settings > Account > allow logging in from other devices; the label is ' +
|
|
12
|
+
'localized) enabled — without it LINE will not offer this device a sign-in ' +
|
|
13
|
+
'prompt at all.\n';
|
|
14
|
+
/**
|
|
15
|
+
* Step-by-step instructions for turning the setting on, returned when the
|
|
16
|
+
* human says it is off or that they do not know where it is.
|
|
17
|
+
*/
|
|
18
|
+
const PRIMARY_DEVICE_SETTING_STEPS = 'On the PRIMARY phone (the one already logged in to this LINE account):\n\n' +
|
|
19
|
+
'1. Open LINE and go to the 主頁 / Home tab.\n' +
|
|
20
|
+
'2. Tap the gear icon in the top corner to open 設定 (Settings).\n' +
|
|
21
|
+
'3. Open 我的帳號 (Account).\n' +
|
|
22
|
+
'4. Turn ON 允許自其他裝置登入 (the toggle allowing login from other ' +
|
|
23
|
+
'devices — the exact wording is localized).\n\n' +
|
|
24
|
+
'This toggle is what lets LINE prompt a second device to sign in. With it ' +
|
|
25
|
+
'off, nothing at all appears on the phone and the login simply never ' +
|
|
26
|
+
'completes.';
|
|
27
|
+
/** Answers offered by the pre-flight prerequisite check. */
|
|
28
|
+
const PREREQ_ANSWERS = {
|
|
29
|
+
enabled: 'enabled',
|
|
30
|
+
notEnabled: 'not_enabled',
|
|
31
|
+
unsure: 'unsure',
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* The prerequisite question, in the 2026-07-28 `oneOf`-with-`title`
|
|
35
|
+
* single-select shape (`TitledSingleSelectEnumSchema`) so each choice carries
|
|
36
|
+
* human-facing text of its own, rather than an enum of bare machine values
|
|
37
|
+
* the client has to render raw.
|
|
38
|
+
*/
|
|
39
|
+
const PREREQ_SCHEMA_PROPERTY = {
|
|
40
|
+
type: 'string',
|
|
41
|
+
title: 'Is 允許自其他裝置登入 enabled on your primary phone?',
|
|
42
|
+
description: `On the phone already logged in to LINE: ${PRIMARY_DEVICE_SETTING_PATH}. ` +
|
|
43
|
+
'Without it, LINE never prompts this device and the login cannot finish.',
|
|
44
|
+
oneOf: [
|
|
45
|
+
{ const: PREREQ_ANSWERS.enabled, title: 'Yes — it is already on' },
|
|
46
|
+
{ const: PREREQ_ANSWERS.notEnabled, title: 'No / not yet — show me how' },
|
|
47
|
+
{
|
|
48
|
+
const: PREREQ_ANSWERS.unsure,
|
|
49
|
+
title: "I don't know where that setting is",
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Build the one form shown before a login starts: the prerequisite, plus
|
|
55
|
+
* `phone`/`region` when the caller did not supply them.
|
|
56
|
+
*
|
|
57
|
+
* Both live in ONE form on purpose — the human answers a single prompt, and
|
|
58
|
+
* the prerequisite is raised while it can still change the outcome.
|
|
59
|
+
*
|
|
60
|
+
* @param phone - Phone already supplied by the tool call, if any.
|
|
61
|
+
* @param region - Region already supplied by the tool call, if any.
|
|
62
|
+
* @returns The elicitation body, for `elicitInput` or `inputRequired.elicit`.
|
|
63
|
+
*/
|
|
64
|
+
export function buildPreflightForm(phone, region) {
|
|
65
|
+
const needsCredentials = !phone || !region;
|
|
66
|
+
const properties = {
|
|
67
|
+
primaryDeviceLogin: PREREQ_SCHEMA_PROPERTY,
|
|
68
|
+
};
|
|
69
|
+
const required = ['primaryDeviceLogin'];
|
|
70
|
+
if (needsCredentials) {
|
|
71
|
+
properties.phone = {
|
|
72
|
+
type: 'string',
|
|
73
|
+
title: 'Phone number',
|
|
74
|
+
description: 'E.164 form, e.g. +8869XXXXXXXX.',
|
|
75
|
+
...(phone ? { default: phone } : {}),
|
|
76
|
+
};
|
|
77
|
+
properties.region = {
|
|
78
|
+
type: 'string',
|
|
79
|
+
title: 'Region',
|
|
80
|
+
enum: LOGIN_REGIONS,
|
|
81
|
+
...(region ? { default: region } : {}),
|
|
82
|
+
};
|
|
83
|
+
required.push('phone', 'region');
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
message: 'Yomi signs in as a SECOND LINE device. That requires ' +
|
|
87
|
+
`${PRIMARY_DEVICE_SETTING_PATH} to be enabled on the phone already ` +
|
|
88
|
+
'logged in — confirm that below' +
|
|
89
|
+
(needsCredentials
|
|
90
|
+
? ', along with your number and region.'
|
|
91
|
+
: ' before the login starts.'),
|
|
92
|
+
requestedSchema: { type: 'object', properties, required },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Read an accepted pre-flight form back. Shared by every path so the answer
|
|
97
|
+
* is interpreted identically no matter how it was collected.
|
|
98
|
+
*
|
|
99
|
+
* @param content - The accepted form content (untrusted client input).
|
|
100
|
+
* @param phone - Phone supplied by the tool call, used when the form did not
|
|
101
|
+
* carry the field because it was already known.
|
|
102
|
+
* @param region - Region, same rule.
|
|
103
|
+
* @returns What the login should do next.
|
|
104
|
+
*/
|
|
105
|
+
export function readPreflightForm(content, phone, region) {
|
|
106
|
+
const answer = String(content?.primaryDeviceLogin ?? '');
|
|
107
|
+
if (answer !== PREREQ_ANSWERS.enabled) {
|
|
108
|
+
return { ok: false, reason: 'prerequisite', answer };
|
|
109
|
+
}
|
|
110
|
+
const collectedPhone = String(content?.phone ?? phone ?? '');
|
|
111
|
+
const collectedRegion = String(content?.region ?? region ?? '');
|
|
112
|
+
if (!collectedPhone || !collectedRegion) {
|
|
113
|
+
return { ok: false, reason: 'cancelled' };
|
|
114
|
+
}
|
|
115
|
+
return { ok: true, phone: collectedPhone, region: collectedRegion };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The numbered steps for acting on an issued PIN. Step 0 repeats the
|
|
119
|
+
* prerequisite even though the pre-flight form already asked about it: a
|
|
120
|
+
* human who answered "yes" without checking finds out here, while the code
|
|
121
|
+
* is still valid.
|
|
122
|
+
*
|
|
123
|
+
* @param pin - The PIN LINE just issued.
|
|
124
|
+
* @returns The steps, ending in a blank line.
|
|
125
|
+
*/
|
|
126
|
+
export function pinSteps(pin) {
|
|
127
|
+
const lifetimeSeconds = Math.round(LINE_PIN_CODE_LIFETIME_MS / 1000);
|
|
128
|
+
return (`LINE PIN:\n\n ${pin}\n\n` +
|
|
129
|
+
PRIMARY_DEVICE_SETTING_NOTE +
|
|
130
|
+
'1. Open LINE on the primary phone. If a verification prompt does not ' +
|
|
131
|
+
'appear immediately, bringing the app to the foreground surfaces it.\n' +
|
|
132
|
+
`2. Enter this PIN and tick the device that is signing in, then tap ` +
|
|
133
|
+
`「用戶確認」 — LINE gives you ${lifetimeSeconds} seconds (about 3 ` +
|
|
134
|
+
'minutes) from when the code was shown to do this.\n' +
|
|
135
|
+
'3. LINE will then ask that same phone to approve this new device ' +
|
|
136
|
+
'signing in — approve it there.\n\n');
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The steps when a stored login certificate skipped the PIN entirely — the
|
|
140
|
+
* flow is already waiting on phone approval of this device.
|
|
141
|
+
*
|
|
142
|
+
* @returns The steps, ending in a blank line.
|
|
143
|
+
*/
|
|
144
|
+
export function certSteps() {
|
|
145
|
+
return ('No PIN needed — LINE recognized a stored login certificate from a ' +
|
|
146
|
+
'previous login, so the PIN step was skipped.\n\n' +
|
|
147
|
+
PRIMARY_DEVICE_SETTING_NOTE +
|
|
148
|
+
'1. Bring the LINE app to the foreground on your primary phone — it ' +
|
|
149
|
+
'should prompt you to approve this new device signing in.\n' +
|
|
150
|
+
'2. Approve the device from there.\n\n');
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The acknowledgement form shown alongside an issued PIN (or alongside the
|
|
154
|
+
* cert-skipped approval prompt).
|
|
155
|
+
*
|
|
156
|
+
* @param pin - The PIN, or `null` when a stored certificate skipped it.
|
|
157
|
+
* @returns The elicitation body.
|
|
158
|
+
*/
|
|
159
|
+
export function buildAcknowledgementForm(pin) {
|
|
160
|
+
return {
|
|
161
|
+
message: (pin ? pinSteps(pin) : certSteps()) +
|
|
162
|
+
'Acknowledge below once you have done this.',
|
|
163
|
+
requestedSchema: {
|
|
164
|
+
type: 'object',
|
|
165
|
+
properties: {
|
|
166
|
+
entered: {
|
|
167
|
+
type: 'boolean',
|
|
168
|
+
title: pin
|
|
169
|
+
? 'I entered the PIN and approved the device on my phone'
|
|
170
|
+
: 'I approved the device on my phone',
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Build the tool result returned when the human has not confirmed the
|
|
178
|
+
* prerequisite. Deliberately NOT a tool error: nothing failed, the login was
|
|
179
|
+
* simply never started, and the human has one concrete thing to do.
|
|
180
|
+
*
|
|
181
|
+
* @param answer - Which non-confirming answer they gave, for wording.
|
|
182
|
+
* @returns MCP tool result carrying the enabling steps and the retry.
|
|
183
|
+
*/
|
|
184
|
+
export function prerequisiteNotMetResult(answer) {
|
|
185
|
+
const lead = answer === PREREQ_ANSWERS.unsure
|
|
186
|
+
? 'Login was not started — here is where that setting lives.\n\n'
|
|
187
|
+
: 'Login was not started, because LINE would never prompt the phone.\n\n';
|
|
188
|
+
return {
|
|
189
|
+
content: [
|
|
190
|
+
{
|
|
191
|
+
type: 'text',
|
|
192
|
+
text: lead +
|
|
193
|
+
PRIMARY_DEVICE_SETTING_STEPS +
|
|
194
|
+
'\n\nOnce the toggle is on, call `login` again. Relay these steps ' +
|
|
195
|
+
'to the human as they are — do not summarize the settings path ' +
|
|
196
|
+
'away, it is the whole point.',
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `login` on a MCP 2026-07-28 connection: the multi-round-trip (MRTR) path.
|
|
3
|
+
*
|
|
4
|
+
* That revision removed the server→client request channel — `elicitInput`
|
|
5
|
+
* throws on a modern-era instance before any wire traffic (SDK:
|
|
6
|
+
* `_assertPushApiInServedEra`, "the 2026-07-28 revision has no server→client
|
|
7
|
+
* request channel"). The same conversation is instead carried by
|
|
8
|
+
* `input_required` RESULTS: the handler returns the elicitation it needs,
|
|
9
|
+
* the client fulfils it through its own `elicitation/create` handler, then
|
|
10
|
+
* RE-CALLS `login` with the answers in `inputResponses`.
|
|
11
|
+
*
|
|
12
|
+
* So this file is ./login.ts's pushed elicitation flow turned inside out —
|
|
13
|
+
* same three beats, one tool call each:
|
|
14
|
+
*
|
|
15
|
+
* round 1 no responses → ask the pre-flight form
|
|
16
|
+
* round 2 `preflight` answered → start the login, ask for PIN ack
|
|
17
|
+
* round 3 `ack` answered → await completion, return the profile
|
|
18
|
+
*
|
|
19
|
+
* The in-flight login survives between rounds in ./login-session.ts, exactly
|
|
20
|
+
* as it survives between `login` and `login_complete` on the fallback path.
|
|
21
|
+
*
|
|
22
|
+
* `requestState` (the other half of MRTR) is deliberately NOT used: it
|
|
23
|
+
* round-trips through the client and comes back attacker-controlled, so the
|
|
24
|
+
* spec requires integrity-protecting anything that influences behavior. The
|
|
25
|
+
* only state this flow needs is the pending login, which never leaves the
|
|
26
|
+
* server.
|
|
27
|
+
*/
|
|
28
|
+
import { acceptedContent, inputRequired, inputResponse, } from '@modelcontextprotocol/server';
|
|
29
|
+
import { createCliLogger } from '../../util/log.js';
|
|
30
|
+
import { buildAcknowledgementForm, buildPreflightForm, prerequisiteNotMetResult, readPreflightForm, } from './login-copy.js';
|
|
31
|
+
import { finishPendingLogin, getLivePendingLogin, getPendingLogin, PIN_WAIT_TIMEOUT_MS, startPendingLogin, waitForPin, } from './login-session.js';
|
|
32
|
+
import { jsonText, toolError } from './shared.js';
|
|
33
|
+
const log = createCliLogger('Yomi');
|
|
34
|
+
/** Keys this flow assigns to its embedded input requests. */
|
|
35
|
+
const PREFLIGHT_KEY = 'preflight';
|
|
36
|
+
const ACK_KEY = 'ack';
|
|
37
|
+
/**
|
|
38
|
+
* Round 3 — the human says they have acted on their phone. Await the login
|
|
39
|
+
* `startPendingLogin` began in round 2.
|
|
40
|
+
*
|
|
41
|
+
* @returns The logged-in profile, or an honest error.
|
|
42
|
+
*/
|
|
43
|
+
async function completeRound() {
|
|
44
|
+
const pending = getPendingLogin();
|
|
45
|
+
if (!pending) {
|
|
46
|
+
return toolError('No login is pending — the attempt this acknowledgement belongs to has already finished or ' +
|
|
47
|
+
'expired (LINE gives the human about 3 minutes to act on a shown code). Calling `login` ' +
|
|
48
|
+
'again is safe and starts a fresh attempt.');
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const { mid, displayName } = await finishPendingLogin(pending);
|
|
52
|
+
return {
|
|
53
|
+
content: [
|
|
54
|
+
{
|
|
55
|
+
type: 'text',
|
|
56
|
+
text: jsonText({ loggedIn: true, mid, displayName }),
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
return toolError(error?.message ?? String(error));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Round 2 — the pre-flight form came back accepted and confirming. Start
|
|
67
|
+
* (or reuse) the passwordless login and ask the human to act on the PIN.
|
|
68
|
+
*
|
|
69
|
+
* @param service - LineProtocolService (not yet authenticated).
|
|
70
|
+
* @param phone - Confirmed phone number.
|
|
71
|
+
* @param region - Confirmed region code.
|
|
72
|
+
* @returns An `input_required` result carrying the PIN acknowledgement form,
|
|
73
|
+
* or an honest error.
|
|
74
|
+
*/
|
|
75
|
+
async function startRound(service, phone, region) {
|
|
76
|
+
const active = getLivePendingLogin(Date.now());
|
|
77
|
+
if (active && (active.phone !== phone || active.region !== region)) {
|
|
78
|
+
// A live login for a DIFFERENT number is still in flight. Never hand
|
|
79
|
+
// back its PIN under this call's identity. Name nothing sensitive.
|
|
80
|
+
return toolError('A login for a different phone number is still in progress. Wait for its login code to expire ' +
|
|
81
|
+
'(LINE gives the human about 3 minutes to act on it) before starting a login for another number.');
|
|
82
|
+
}
|
|
83
|
+
const pending = active ?? startPendingLogin(service, phone, region);
|
|
84
|
+
const pin = await waitForPin(pending, PIN_WAIT_TIMEOUT_MS);
|
|
85
|
+
if (!pin && !pending.certSkippedPin) {
|
|
86
|
+
return toolError('LINE has not issued a login PIN within 20s. The attempt is still running in the background ' +
|
|
87
|
+
'(do not worry, nothing was lost) — call `login` again shortly to check for the PIN.');
|
|
88
|
+
}
|
|
89
|
+
log.warn('login.pin', {
|
|
90
|
+
action: pin ? 'returned_in_input_required' : 'skipped_cert_valid',
|
|
91
|
+
});
|
|
92
|
+
// The PIN travels inside the elicitation the client is about to render,
|
|
93
|
+
// and the login keeps running while the human acts on it — this handler
|
|
94
|
+
// returns immediately rather than blocking through LINE's ~3-minute
|
|
95
|
+
// window, so the human's clock starts now and not after a timeout.
|
|
96
|
+
return inputRequired({
|
|
97
|
+
inputRequests: {
|
|
98
|
+
[ACK_KEY]: inputRequired.elicit(buildAcknowledgementForm(pin)),
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Handle `login` on a modern-era connection. Which round this call is comes
|
|
104
|
+
* from the responses it carries: none means round 1.
|
|
105
|
+
*
|
|
106
|
+
* @param service - LineProtocolService (not yet authenticated).
|
|
107
|
+
* @param args - Tool arguments.
|
|
108
|
+
* @param inputResponses - `ctx.mcpReq.inputResponses` from a retried call.
|
|
109
|
+
* Untrusted client input — read only through the SDK's response readers.
|
|
110
|
+
* @returns An `input_required` result, or a terminal tool result.
|
|
111
|
+
*/
|
|
112
|
+
export async function handleLoginMrtr(service, args, inputResponses) {
|
|
113
|
+
// Round 3 first: a client that accumulates responses across rounds would
|
|
114
|
+
// send both keys, and the later beat is the one to act on.
|
|
115
|
+
const ack = inputResponse(inputResponses, ACK_KEY);
|
|
116
|
+
if (ack.kind === 'elicit') {
|
|
117
|
+
if (ack.action !== 'accept') {
|
|
118
|
+
return toolError('Login was cancelled by the user.');
|
|
119
|
+
}
|
|
120
|
+
return await completeRound();
|
|
121
|
+
}
|
|
122
|
+
const preflight = inputResponse(inputResponses, PREFLIGHT_KEY);
|
|
123
|
+
if (preflight.kind === 'elicit') {
|
|
124
|
+
if (preflight.action !== 'accept') {
|
|
125
|
+
return toolError('Login was cancelled by the user.');
|
|
126
|
+
}
|
|
127
|
+
const outcome = readPreflightForm(acceptedContent(inputResponses, PREFLIGHT_KEY), args.phone, args.region);
|
|
128
|
+
if (!outcome.ok) {
|
|
129
|
+
if (outcome.reason === 'prerequisite') {
|
|
130
|
+
log.warn('login.prerequisite', { answer: outcome.answer });
|
|
131
|
+
return prerequisiteNotMetResult(outcome.answer);
|
|
132
|
+
}
|
|
133
|
+
return toolError('Login was cancelled by the user.');
|
|
134
|
+
}
|
|
135
|
+
return await startRound(service, outcome.phone, outcome.region);
|
|
136
|
+
}
|
|
137
|
+
// Round 1. Persisted credentials still pre-fill the form, so a returning
|
|
138
|
+
// user only confirms rather than retypes.
|
|
139
|
+
const phone = args.phone ??
|
|
140
|
+
(await service.credentialStore?.get?.('line_phone')) ??
|
|
141
|
+
undefined;
|
|
142
|
+
const region = args.region ??
|
|
143
|
+
(await service.credentialStore?.get?.('line_region')) ??
|
|
144
|
+
undefined;
|
|
145
|
+
return inputRequired({
|
|
146
|
+
inputRequests: {
|
|
147
|
+
[PREFLIGHT_KEY]: inputRequired.elicit(buildPreflightForm(phone, region)),
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { runPwlessLogin } from '../../cli/login.js';
|
|
2
|
+
import { LINE_PIN_CODE_LIFETIME_MS } from '../../line/auth/pwless/index.js';
|
|
3
|
+
import { createCliLogger } from '../../util/log.js';
|
|
4
|
+
const log = createCliLogger('Yomi');
|
|
5
|
+
/**
|
|
6
|
+
* How long a live pending login may be reused by a later `login` call for
|
|
7
|
+
* the same phone/region, before treating it as stale and starting fresh.
|
|
8
|
+
*
|
|
9
|
+
* This is bound to LINE's real PIN code lifetime (~line/auth/pwless/index.ts,
|
|
10
|
+
* an external fact from LINE Help Center, ~3 minutes) — NOT to how long
|
|
11
|
+
* yomi's client is willing to keep polling (~16 minutes). A login started
|
|
12
|
+
* 90 seconds ago is still very much alive and its PIN is still valid; the
|
|
13
|
+
* only thing that actually kills a pending login early is LINE's own code
|
|
14
|
+
* expiring, so that is the correct bound for "is this still worth reusing".
|
|
15
|
+
*/
|
|
16
|
+
const PIN_WINDOW_MS = LINE_PIN_CODE_LIFETIME_MS;
|
|
17
|
+
/** How long a `login` call waits for LINE to issue a PIN before giving up. */
|
|
18
|
+
export const PIN_WAIT_TIMEOUT_MS = 20000;
|
|
19
|
+
let pendingLogin = null;
|
|
20
|
+
/** @returns The in-flight login, or null when none is pending. */
|
|
21
|
+
export function getPendingLogin() {
|
|
22
|
+
return pendingLogin;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The in-flight login only if it is still worth reusing: started within
|
|
26
|
+
* LINE's PIN lifetime.
|
|
27
|
+
*
|
|
28
|
+
* @param now - Current epoch millis.
|
|
29
|
+
* @returns The live pending login, or null.
|
|
30
|
+
*/
|
|
31
|
+
export function getLivePendingLogin(now) {
|
|
32
|
+
if (!pendingLogin) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return now - pendingLogin.startedAt < PIN_WINDOW_MS ? pendingLogin : null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Start a new passwordless login in the background and register it as the
|
|
39
|
+
* one in-flight pending login.
|
|
40
|
+
*
|
|
41
|
+
* The stored promise is the actual `runPwlessLogin` promise, so a later call
|
|
42
|
+
* can await it and see the real success/failure. A `.catch()` is attached
|
|
43
|
+
* immediately (its result discarded, not stored) so an abandoned login — one
|
|
44
|
+
* nobody ever finishes — never surfaces as an unhandled rejection; this does
|
|
45
|
+
* not affect what `promise` itself resolves/rejects with.
|
|
46
|
+
*
|
|
47
|
+
* @param service - LineProtocolService (not yet authenticated).
|
|
48
|
+
* @param phone - Phone number in E.164 form.
|
|
49
|
+
* @param region - Region code.
|
|
50
|
+
* @returns The newly registered pending-login record.
|
|
51
|
+
*/
|
|
52
|
+
export function startPendingLogin(service, phone, region) {
|
|
53
|
+
let resolvePinReady = () => { };
|
|
54
|
+
const pinReady = new Promise((resolve) => {
|
|
55
|
+
resolvePinReady = resolve;
|
|
56
|
+
});
|
|
57
|
+
const pending = {
|
|
58
|
+
phone,
|
|
59
|
+
region,
|
|
60
|
+
pin: null,
|
|
61
|
+
certSkippedPin: false,
|
|
62
|
+
startedAt: Date.now(),
|
|
63
|
+
pinReady,
|
|
64
|
+
};
|
|
65
|
+
const onPin = (pin) => {
|
|
66
|
+
pending.pin = pin;
|
|
67
|
+
resolvePinReady();
|
|
68
|
+
};
|
|
69
|
+
const onBiometric = () => {
|
|
70
|
+
log.warn('login.waiting_biometric', {
|
|
71
|
+
action: 'approve the new device on your phone',
|
|
72
|
+
});
|
|
73
|
+
// Reaching biometric wait with no PIN ever set means a valid stored
|
|
74
|
+
// certificate skipped the PIN step entirely (see LinePwlessLogin.login,
|
|
75
|
+
// step 2-3) — resolve `pinReady` now so `waitForPin` doesn't block out
|
|
76
|
+
// its full timeout waiting for a PIN that will never arrive.
|
|
77
|
+
if (!pending.pin) {
|
|
78
|
+
pending.certSkippedPin = true;
|
|
79
|
+
resolvePinReady();
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const promise = runPwlessLogin(service, phone, region, {
|
|
83
|
+
onPin,
|
|
84
|
+
onWaitingBiometric: onBiometric,
|
|
85
|
+
});
|
|
86
|
+
promise.catch(() => {
|
|
87
|
+
// Swallow here only to prevent an unhandled-rejection warning for a
|
|
88
|
+
// login nobody completes; the real error still surfaces to whoever
|
|
89
|
+
// awaits `promise` below.
|
|
90
|
+
});
|
|
91
|
+
pending.promise = promise;
|
|
92
|
+
pendingLogin = pending;
|
|
93
|
+
return pendingLogin;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Wait for a pending login's PIN to arrive, bounded by a timeout.
|
|
97
|
+
*
|
|
98
|
+
* @param pending - The pending login to wait on.
|
|
99
|
+
* @param timeoutMs - Maximum time to wait.
|
|
100
|
+
* @returns The PIN, or `null` if it did not arrive within `timeoutMs` (also
|
|
101
|
+
* the answer when a stored certificate skipped the PIN — check
|
|
102
|
+
* `certSkippedPin` to tell the two apart).
|
|
103
|
+
*/
|
|
104
|
+
export async function waitForPin(pending, timeoutMs) {
|
|
105
|
+
if (pending.pin) {
|
|
106
|
+
return pending.pin;
|
|
107
|
+
}
|
|
108
|
+
return await Promise.race([
|
|
109
|
+
pending.pinReady.then(() => pending.pin ?? null),
|
|
110
|
+
new Promise((resolve) => {
|
|
111
|
+
setTimeout(() => resolve(null), timeoutMs);
|
|
112
|
+
}),
|
|
113
|
+
]);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Await the in-flight login to completion and clear it either way. Never
|
|
117
|
+
* touches credentials on the failure path — `runPwlessLogin` owns that.
|
|
118
|
+
*
|
|
119
|
+
* @param pending - The pending login to finish.
|
|
120
|
+
* @returns The logged-in profile.
|
|
121
|
+
* @throws Whatever `runPwlessLogin` failed with.
|
|
122
|
+
*/
|
|
123
|
+
export async function finishPendingLogin(pending) {
|
|
124
|
+
try {
|
|
125
|
+
const result = await pending.promise;
|
|
126
|
+
log.info('login.complete', { mid: result.mid });
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
pendingLogin = null;
|
|
131
|
+
}
|
|
132
|
+
}
|