typeclaw 0.32.1 → 0.34.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/auth.schema.json +66 -0
- package/cron.schema.json +26 -2
- package/package.json +1 -1
- package/secrets.schema.json +66 -0
- package/src/agent/index.ts +7 -3
- package/src/agent/session-origin.ts +17 -0
- package/src/agent/subagent-completion-reminder.ts +14 -1
- package/src/agent/subagent-drain.ts +2 -0
- package/src/agent/subagents.ts +21 -7
- package/src/agent/tools/channel-disengage.ts +66 -0
- package/src/agent/tools/channel-log.ts +3 -2
- package/src/agent/tools/spawn-subagent.ts +25 -5
- package/src/agent/tools/subagent-output.ts +13 -1
- package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
- package/src/bundled-plugins/memory/memory-logger.ts +7 -0
- package/src/bundled-plugins/researcher/researcher.ts +14 -11
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +2 -0
- package/src/channels/adapters/line-channel-resolver.ts +129 -0
- package/src/channels/adapters/line-classify.ts +80 -0
- package/src/channels/adapters/line-format.ts +11 -0
- package/src/channels/adapters/line.ts +350 -0
- package/src/channels/engagement.ts +4 -2
- package/src/channels/manager.ts +65 -6
- package/src/channels/router.ts +186 -41
- package/src/channels/schema.ts +6 -1
- package/src/cli/channel.ts +112 -1
- package/src/cli/cron.ts +22 -4
- package/src/cli/init.ts +267 -82
- package/src/cli/model.ts +5 -1
- package/src/cli/oauth-callbacks.ts +5 -4
- package/src/cli/provider.ts +41 -10
- package/src/config/providers.ts +366 -7
- package/src/cron/consumer.ts +33 -0
- package/src/cron/count-state.ts +208 -0
- package/src/cron/index.ts +4 -17
- package/src/cron/list.ts +24 -6
- package/src/cron/scheduler.ts +84 -9
- package/src/cron/schema.ts +100 -13
- package/src/doctor/channel-checks.ts +28 -0
- package/src/hostd/daemon.ts +14 -6
- package/src/hostd/protocol.ts +6 -2
- package/src/init/gitignore.ts +1 -1
- package/src/init/index.ts +36 -3
- package/src/init/line-auth.ts +98 -0
- package/src/init/models-dev.ts +3 -0
- package/src/init/run-owner-claim.ts +1 -0
- package/src/init/validate-api-key.ts +15 -0
- package/src/inspect/label.ts +1 -0
- package/src/permissions/match-rule.ts +28 -12
- package/src/permissions/resolve.ts +8 -1
- package/src/role-claim/match-rule.ts +5 -1
- package/src/run/index.ts +41 -4
- package/src/secrets/line-store.ts +112 -0
- package/src/secrets/oauth-xai.ts +342 -0
- package/src/secrets/schema.ts +25 -0
- package/src/secrets/storage.ts +2 -0
- package/src/server/index.ts +17 -4
- package/src/shared/protocol.ts +4 -1
- package/src/skills/typeclaw-channel-line/SKILL.md +46 -0
- package/src/skills/typeclaw-channels/SKILL.md +153 -0
- package/src/skills/typeclaw-config/SKILL.md +54 -184
- package/src/skills/typeclaw-config/references/dockerfile.md +66 -0
- package/src/skills/typeclaw-cron/SKILL.md +68 -14
- package/src/skills/typeclaw-permissions/SKILL.md +3 -3
- package/typeclaw.schema.json +185 -3
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { LineAccountCredentials, LineConfig } from 'agent-messenger/line'
|
|
2
|
+
|
|
3
|
+
import { sendHttp } from '@/hostd/client'
|
|
4
|
+
|
|
5
|
+
import { type LineChannelBlock, lineChannelBlockSchema } from './schema'
|
|
6
|
+
import { SecretsBackend } from './storage'
|
|
7
|
+
|
|
8
|
+
export type SecretsLineCredentialStoreOptions =
|
|
9
|
+
| { mode: 'host'; secretsPath: string }
|
|
10
|
+
| { mode: 'container'; secretsPath: string; hostdUrl: string; restartToken: string; containerName: string }
|
|
11
|
+
|
|
12
|
+
const EMPTY_BLOCK: LineChannelBlock = { currentAccount: null, accounts: {} }
|
|
13
|
+
|
|
14
|
+
export class SecretsLineCredentialStore {
|
|
15
|
+
private readonly backend: SecretsBackend
|
|
16
|
+
private writeChain: Promise<void> = Promise.resolve()
|
|
17
|
+
|
|
18
|
+
constructor(private readonly options: SecretsLineCredentialStoreOptions) {
|
|
19
|
+
this.backend = new SecretsBackend(options.secretsPath)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async load(): Promise<LineConfig> {
|
|
23
|
+
return toLineConfig(this.readBlock())
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async save(config: LineConfig): Promise<void> {
|
|
27
|
+
await this.writeBlock(() => fromLineConfig(config))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async getAccount(id?: string): Promise<LineAccountCredentials | null> {
|
|
31
|
+
const config = await this.load()
|
|
32
|
+
if (id) return config.accounts[id] ?? null
|
|
33
|
+
if (!config.current_account) return null
|
|
34
|
+
return config.accounts[config.current_account] ?? null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async setAccount(account: LineAccountCredentials): Promise<void> {
|
|
38
|
+
await this.writeBlock((block) => {
|
|
39
|
+
const accounts = { ...block.accounts, [account.account_id]: account }
|
|
40
|
+
return { ...block, currentAccount: block.currentAccount ?? account.account_id, accounts }
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async removeAccount(id: string): Promise<void> {
|
|
45
|
+
await this.writeBlock((block) => {
|
|
46
|
+
const accounts = { ...block.accounts }
|
|
47
|
+
delete accounts[id]
|
|
48
|
+
const currentAccount = block.currentAccount === id ? (Object.keys(accounts)[0] ?? null) : block.currentAccount
|
|
49
|
+
return { ...block, currentAccount, accounts }
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async listAccounts(): Promise<Array<LineAccountCredentials & { is_current: boolean }>> {
|
|
54
|
+
const config = await this.load()
|
|
55
|
+
return Object.values(config.accounts).map((account) => ({
|
|
56
|
+
...account,
|
|
57
|
+
is_current: account.account_id === config.current_account,
|
|
58
|
+
}))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async setCurrentAccount(id: string): Promise<void> {
|
|
62
|
+
await this.writeBlock((block) => ({ ...block, currentAccount: id }))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private readBlock(): LineChannelBlock {
|
|
66
|
+
const channels =
|
|
67
|
+
this.options.mode === 'container' ? this.backend.tryReadChannelsSync() : this.backend.readChannelsSync()
|
|
68
|
+
return parseBlock(channels?.line)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private async writeBlock(update: (current: LineChannelBlock) => LineChannelBlock): Promise<void> {
|
|
72
|
+
return this.enqueueWrite(async () => {
|
|
73
|
+
if (this.options.mode === 'container') {
|
|
74
|
+
const next = update(this.readBlock())
|
|
75
|
+
const response = await sendHttp(
|
|
76
|
+
{
|
|
77
|
+
kind: 'secrets-patch',
|
|
78
|
+
containerName: this.options.containerName,
|
|
79
|
+
patch: { channels: { line: next } },
|
|
80
|
+
},
|
|
81
|
+
{ url: this.options.hostdUrl, token: this.options.restartToken },
|
|
82
|
+
)
|
|
83
|
+
if (!response.ok) throw new Error(`secrets-patch failed: ${response.reason}`)
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
await this.backend.updateChannelsAsync(async (channels) => {
|
|
88
|
+
const next = { ...channels, line: update(parseBlock(channels.line)) }
|
|
89
|
+
return { result: undefined, next }
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private enqueueWrite(op: () => Promise<void>): Promise<void> {
|
|
95
|
+
const next = this.writeChain.then(op, op)
|
|
96
|
+
this.writeChain = next.catch(() => {})
|
|
97
|
+
return next
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseBlock(value: unknown): LineChannelBlock {
|
|
102
|
+
if (value === undefined) return EMPTY_BLOCK
|
|
103
|
+
return lineChannelBlockSchema.parse(value)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function toLineConfig(block: LineChannelBlock): LineConfig {
|
|
107
|
+
return { current_account: block.currentAccount, accounts: block.accounts }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function fromLineConfig(config: LineConfig): LineChannelBlock {
|
|
111
|
+
return { currentAccount: config.current_account, accounts: config.accounts }
|
|
112
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { createServer, type Server } from 'node:http'
|
|
2
|
+
|
|
3
|
+
import { registerOAuthProvider } from '@mariozechner/pi-ai/oauth'
|
|
4
|
+
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from '@mariozechner/pi-ai/oauth'
|
|
5
|
+
|
|
6
|
+
// xAI (Grok) OAuth 2.0. xAI runs a standard OIDC authorization server at
|
|
7
|
+
// auth.x.ai that supports BOTH authorization-code + PKCE (loopback callback)
|
|
8
|
+
// and the device-authorization grant. We implement the auth-code path with a
|
|
9
|
+
// localhost callback server (same UX as pi-ai's anthropic provider) plus a
|
|
10
|
+
// manual-paste fallback for cross-device/SSH flows.
|
|
11
|
+
//
|
|
12
|
+
// There is no public developer console to register a third-party OAuth client,
|
|
13
|
+
// so — like every OSS Grok integration (Grok CLI, opencode, hermes-agent,
|
|
14
|
+
// pi-xai-oauth) — we reuse the Grok CLI's public client id. The `plan=generic`
|
|
15
|
+
// query param is load-bearing: loopback OAuth against this client id is
|
|
16
|
+
// rejected without it. `referrer` is attribution only.
|
|
17
|
+
//
|
|
18
|
+
// Endpoints below are the live values from
|
|
19
|
+
// https://auth.x.ai/.well-known/openid-configuration. The token endpoint speaks
|
|
20
|
+
// application/x-www-form-urlencoded (OAuth2 default) — NOT JSON like Anthropic.
|
|
21
|
+
export const XAI_OAUTH_PROVIDER_ID = 'xai'
|
|
22
|
+
|
|
23
|
+
const CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828'
|
|
24
|
+
const AUTHORIZE_URL = 'https://auth.x.ai/oauth2/authorize'
|
|
25
|
+
const TOKEN_URL = 'https://auth.x.ai/oauth2/token'
|
|
26
|
+
const CALLBACK_HOST = '127.0.0.1'
|
|
27
|
+
const CALLBACK_PORT = 56121
|
|
28
|
+
const CALLBACK_PATH = '/callback'
|
|
29
|
+
const REDIRECT_URI = `http://${CALLBACK_HOST}:${CALLBACK_PORT}${CALLBACK_PATH}`
|
|
30
|
+
const SCOPES = 'openid profile email offline_access grok-cli:access api:access'
|
|
31
|
+
const REFERRER = 'typeclaw'
|
|
32
|
+
// Refresh slightly early so an in-flight request never races expiry.
|
|
33
|
+
const EXPIRY_SKEW_MS = 5 * 60 * 1000
|
|
34
|
+
const REQUEST_TIMEOUT_MS = 30_000
|
|
35
|
+
|
|
36
|
+
type XaiTokenResponse = {
|
|
37
|
+
access_token?: string
|
|
38
|
+
refresh_token?: string
|
|
39
|
+
expires_in?: number
|
|
40
|
+
token_type?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function base64UrlEncode(bytes: Uint8Array): string {
|
|
44
|
+
let binary = ''
|
|
45
|
+
for (const byte of bytes) binary += String.fromCharCode(byte)
|
|
46
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// PKCE (RFC 7636, S256). pi-ai keeps its `generatePKCE` helper out of the
|
|
50
|
+
// public `/oauth` barrel, so we generate the verifier/challenge with Web Crypto
|
|
51
|
+
// directly — available in both Node and Bun.
|
|
52
|
+
async function generatePkce(): Promise<{ verifier: string; challenge: string }> {
|
|
53
|
+
const verifier = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)))
|
|
54
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
|
|
55
|
+
return { verifier, challenge: base64UrlEncode(new Uint8Array(digest)) }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseAuthorizationInput(input: string): { code?: string; state?: string } {
|
|
59
|
+
const value = input.trim()
|
|
60
|
+
if (!value) return {}
|
|
61
|
+
try {
|
|
62
|
+
const url = new URL(value)
|
|
63
|
+
return {
|
|
64
|
+
code: url.searchParams.get('code') ?? undefined,
|
|
65
|
+
state: url.searchParams.get('state') ?? undefined,
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// Not a full URL — fall through to query-string / bare-code handling.
|
|
69
|
+
}
|
|
70
|
+
if (value.includes('code=')) {
|
|
71
|
+
const params = new URLSearchParams(value)
|
|
72
|
+
return {
|
|
73
|
+
code: params.get('code') ?? undefined,
|
|
74
|
+
state: params.get('state') ?? undefined,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { code: value }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type CallbackServer = {
|
|
81
|
+
server: Server
|
|
82
|
+
cancelWait: () => void
|
|
83
|
+
waitForCode: () => Promise<{ code: string; state: string | null } | null>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function successHtml(): string {
|
|
87
|
+
return '<!doctype html><html><body style="font-family:sans-serif;padding:2rem"><h2>xAI authentication complete.</h2><p>You can close this window and return to the terminal.</p></body></html>'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The OAuth `error` query param is provider-supplied and reflected into the
|
|
91
|
+
// callback page, so escape it to keep a crafted callback URL
|
|
92
|
+
// (`?error=<script>…`) from injecting markup into the local page.
|
|
93
|
+
function escapeHtml(value: string): string {
|
|
94
|
+
return value
|
|
95
|
+
.replaceAll('&', '&')
|
|
96
|
+
.replaceAll('<', '<')
|
|
97
|
+
.replaceAll('>', '>')
|
|
98
|
+
.replaceAll('"', '"')
|
|
99
|
+
.replaceAll("'", ''')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function errorHtml(message: string): string {
|
|
103
|
+
return `<!doctype html><html><body style="font-family:sans-serif;padding:2rem"><h2>xAI authentication failed.</h2><p>${escapeHtml(message)}</p></body></html>`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Resolves to `null` when the fixed loopback port can't be bound (EADDRINUSE,
|
|
107
|
+
// sandbox bind restriction). The caller then falls back to manual-paste mode
|
|
108
|
+
// rather than failing the whole login — the browser callback is a convenience,
|
|
109
|
+
// not a hard requirement, since the user can always paste the redirect URL.
|
|
110
|
+
function startCallbackServer(expectedState: string): Promise<CallbackServer | null> {
|
|
111
|
+
return new Promise((resolve) => {
|
|
112
|
+
let settle: ((value: { code: string; state: string | null } | null) => void) | undefined
|
|
113
|
+
const waitForCodePromise = new Promise<{ code: string; state: string | null } | null>((resolveWait) => {
|
|
114
|
+
let settled = false
|
|
115
|
+
settle = (value) => {
|
|
116
|
+
if (settled) return
|
|
117
|
+
settled = true
|
|
118
|
+
resolveWait(value)
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const server = createServer((req, res) => {
|
|
123
|
+
try {
|
|
124
|
+
const url = new URL(req.url || '', `http://${CALLBACK_HOST}`)
|
|
125
|
+
if (url.pathname !== CALLBACK_PATH) {
|
|
126
|
+
res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
127
|
+
res.end(errorHtml('Callback route not found.'))
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
const code = url.searchParams.get('code')
|
|
131
|
+
const state = url.searchParams.get('state')
|
|
132
|
+
const error = url.searchParams.get('error')
|
|
133
|
+
if (error) {
|
|
134
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
135
|
+
res.end(errorHtml(`Error: ${error}`))
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
if (!code) {
|
|
139
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
140
|
+
res.end(errorHtml('Missing code parameter.'))
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
if (state !== expectedState) {
|
|
144
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
145
|
+
res.end(errorHtml('State mismatch.'))
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
149
|
+
res.end(successHtml())
|
|
150
|
+
settle?.({ code, state })
|
|
151
|
+
} catch {
|
|
152
|
+
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
153
|
+
res.end('Internal error')
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
server.on('error', () => {
|
|
158
|
+
server.close()
|
|
159
|
+
resolve(null)
|
|
160
|
+
})
|
|
161
|
+
server.listen(CALLBACK_PORT, CALLBACK_HOST, () => {
|
|
162
|
+
resolve({
|
|
163
|
+
server,
|
|
164
|
+
cancelWait: () => settle?.(null),
|
|
165
|
+
waitForCode: () => waitForCodePromise,
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export type FetchFn = (input: string, init: RequestInit) => Promise<Response>
|
|
172
|
+
|
|
173
|
+
async function postForm(url: string, body: Record<string, string>, fetchImpl: FetchFn): Promise<XaiTokenResponse> {
|
|
174
|
+
const response = await fetchImpl(url, {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
headers: {
|
|
177
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
178
|
+
Accept: 'application/json',
|
|
179
|
+
},
|
|
180
|
+
body: new URLSearchParams(body).toString(),
|
|
181
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
182
|
+
})
|
|
183
|
+
const text = await response.text()
|
|
184
|
+
if (!response.ok) {
|
|
185
|
+
throw new Error(`xAI OAuth request failed. status=${response.status}; url=${url}; body=${text}`)
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
return JSON.parse(text) as XaiTokenResponse
|
|
189
|
+
} catch {
|
|
190
|
+
throw new Error(`xAI OAuth returned invalid JSON. url=${url}; body=${text}`)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function toCredentials(token: XaiTokenResponse): OAuthCredentials {
|
|
195
|
+
if (!token.access_token || !token.refresh_token || token.expires_in === undefined) {
|
|
196
|
+
throw new Error('xAI OAuth response missing access_token, refresh_token, or expires_in')
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
access: token.access_token,
|
|
200
|
+
refresh: token.refresh_token,
|
|
201
|
+
expires: Date.now() + token.expires_in * 1000 - EXPIRY_SKEW_MS,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function exchangeAuthorizationCode(
|
|
206
|
+
code: string,
|
|
207
|
+
verifier: string,
|
|
208
|
+
fetchImpl: FetchFn,
|
|
209
|
+
): Promise<OAuthCredentials> {
|
|
210
|
+
const token = await postForm(
|
|
211
|
+
TOKEN_URL,
|
|
212
|
+
{
|
|
213
|
+
grant_type: 'authorization_code',
|
|
214
|
+
client_id: CLIENT_ID,
|
|
215
|
+
code,
|
|
216
|
+
redirect_uri: REDIRECT_URI,
|
|
217
|
+
code_verifier: verifier,
|
|
218
|
+
},
|
|
219
|
+
fetchImpl,
|
|
220
|
+
)
|
|
221
|
+
return toCredentials(token)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function loginXai(callbacks: OAuthLoginCallbacks, fetchImpl: FetchFn = fetch): Promise<OAuthCredentials> {
|
|
225
|
+
const { verifier, challenge } = await generatePkce()
|
|
226
|
+
const server = await startCallbackServer(verifier)
|
|
227
|
+
let code: string | undefined
|
|
228
|
+
try {
|
|
229
|
+
const authParams = new URLSearchParams({
|
|
230
|
+
client_id: CLIENT_ID,
|
|
231
|
+
response_type: 'code',
|
|
232
|
+
redirect_uri: REDIRECT_URI,
|
|
233
|
+
scope: SCOPES,
|
|
234
|
+
code_challenge: challenge,
|
|
235
|
+
code_challenge_method: 'S256',
|
|
236
|
+
state: verifier,
|
|
237
|
+
plan: 'generic',
|
|
238
|
+
referrer: REFERRER,
|
|
239
|
+
})
|
|
240
|
+
callbacks.onAuth({
|
|
241
|
+
url: `${AUTHORIZE_URL}?${authParams.toString()}`,
|
|
242
|
+
instructions:
|
|
243
|
+
'Complete login in your browser. Grok shows a code to copy on the "could not establish connection" page — paste that code here. If the browser is on another machine, paste the code (or the final redirect URL) here.',
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
if (server && callbacks.onManualCodeInput) {
|
|
247
|
+
// Race the local callback server against a manual paste: whichever lands
|
|
248
|
+
// a code first wins (cross-device/SSH logins can't reach the loopback).
|
|
249
|
+
let manualInput: string | undefined
|
|
250
|
+
let manualError: Error | undefined
|
|
251
|
+
const manualPromise = callbacks
|
|
252
|
+
.onManualCodeInput()
|
|
253
|
+
.then((input) => {
|
|
254
|
+
manualInput = input
|
|
255
|
+
server.cancelWait()
|
|
256
|
+
})
|
|
257
|
+
.catch((err) => {
|
|
258
|
+
manualError = err instanceof Error ? err : new Error(String(err))
|
|
259
|
+
server.cancelWait()
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
const result = await server.waitForCode()
|
|
263
|
+
if (manualError) throw manualError
|
|
264
|
+
if (result?.code) {
|
|
265
|
+
code = result.code
|
|
266
|
+
} else if (manualInput) {
|
|
267
|
+
code = parseManualCode(manualInput, verifier)
|
|
268
|
+
}
|
|
269
|
+
if (!code) {
|
|
270
|
+
await manualPromise
|
|
271
|
+
if (manualError) throw manualError
|
|
272
|
+
if (manualInput) {
|
|
273
|
+
code = parseManualCode(manualInput, verifier)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
} else if (server) {
|
|
277
|
+
const result = await server.waitForCode()
|
|
278
|
+
if (result?.code) code = result.code
|
|
279
|
+
} else if (callbacks.onManualCodeInput) {
|
|
280
|
+
// No callback server bound — manual paste is the only path to a code.
|
|
281
|
+
code = parseManualCode(await callbacks.onManualCodeInput(), verifier)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (!code) {
|
|
285
|
+
const input = await callbacks.onPrompt({
|
|
286
|
+
message: 'Paste the authorization code or full redirect URL:',
|
|
287
|
+
placeholder: REDIRECT_URI,
|
|
288
|
+
})
|
|
289
|
+
code = parseManualCode(input, verifier)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (!code) throw new Error('Missing authorization code')
|
|
293
|
+
|
|
294
|
+
callbacks.onProgress?.('Exchanging authorization code for tokens...')
|
|
295
|
+
return await exchangeAuthorizationCode(code, verifier, fetchImpl)
|
|
296
|
+
} finally {
|
|
297
|
+
server?.server.close()
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function parseManualCode(input: string, verifier: string): string | undefined {
|
|
302
|
+
const parsed = parseAuthorizationInput(input)
|
|
303
|
+
if (parsed.state && parsed.state !== verifier) throw new Error('OAuth state mismatch')
|
|
304
|
+
return parsed.code
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function refreshXaiToken(refreshToken: string, fetchImpl: FetchFn = fetch): Promise<OAuthCredentials> {
|
|
308
|
+
const token = await postForm(
|
|
309
|
+
TOKEN_URL,
|
|
310
|
+
{
|
|
311
|
+
grant_type: 'refresh_token',
|
|
312
|
+
client_id: CLIENT_ID,
|
|
313
|
+
refresh_token: refreshToken,
|
|
314
|
+
},
|
|
315
|
+
fetchImpl,
|
|
316
|
+
)
|
|
317
|
+
// Some OAuth servers omit a rotated refresh token on refresh; keep the prior
|
|
318
|
+
// one so the credential stays usable across the next cycle.
|
|
319
|
+
return toCredentials({ ...token, refresh_token: token.refresh_token ?? refreshToken })
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export const xaiOAuthProvider: OAuthProviderInterface = {
|
|
323
|
+
id: XAI_OAUTH_PROVIDER_ID,
|
|
324
|
+
name: 'xAI (Grok)',
|
|
325
|
+
usesCallbackServer: true,
|
|
326
|
+
login: loginXai,
|
|
327
|
+
refreshToken: (credentials) => refreshXaiToken(credentials.refresh),
|
|
328
|
+
getApiKey: (credentials) => credentials.access,
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let registered = false
|
|
332
|
+
|
|
333
|
+
// pi-ai ships no built-in xAI OAuth provider, so we register ours. Idempotent
|
|
334
|
+
// and called from `createSecretsStoreForAgent` — the single chokepoint both the
|
|
335
|
+
// init-time login path and the container-runtime auth/refresh path go through —
|
|
336
|
+
// so the provider is always present before `AuthStorage.login()` /
|
|
337
|
+
// `getApiKey()` look it up via `getOAuthProvider('xai')`.
|
|
338
|
+
export function registerXaiOAuthProvider(): void {
|
|
339
|
+
if (registered) return
|
|
340
|
+
registerOAuthProvider(xaiOAuthProvider)
|
|
341
|
+
registered = true
|
|
342
|
+
}
|
package/src/secrets/schema.ts
CHANGED
|
@@ -55,6 +55,28 @@ const githubChannelSchema = z.object({
|
|
|
55
55
|
webhookSecret: secretFieldSchema,
|
|
56
56
|
})
|
|
57
57
|
|
|
58
|
+
const lineDeviceSchema = z.enum(['DESKTOPWIN', 'DESKTOPMAC', 'ANDROID', 'ANDROIDSECONDARY', 'IOS', 'IOSIPAD'])
|
|
59
|
+
|
|
60
|
+
// LINE persists a long-lived auth token (+ optional certificate that lets a
|
|
61
|
+
// later re-login skip the e-mail/PIN step on the same device). There is no
|
|
62
|
+
// encrypted-password / renewal-cron path the way KakaoTalk has — LINE tokens
|
|
63
|
+
// don't expire on a fixed short schedule, so the renewal fields are absent by
|
|
64
|
+
// design.
|
|
65
|
+
export const lineAccountRecordSchema = z.object({
|
|
66
|
+
account_id: z.string(),
|
|
67
|
+
auth_token: z.string(),
|
|
68
|
+
certificate: z.string().optional(),
|
|
69
|
+
device: lineDeviceSchema,
|
|
70
|
+
display_name: z.string().optional(),
|
|
71
|
+
created_at: z.string(),
|
|
72
|
+
updated_at: z.string(),
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
export const lineChannelBlockSchema = z.object({
|
|
76
|
+
currentAccount: z.string().nullable(),
|
|
77
|
+
accounts: z.record(z.string(), lineAccountRecordSchema),
|
|
78
|
+
})
|
|
79
|
+
|
|
58
80
|
// Encrypted password envelope produced by src/secrets/encryption.ts. Optional
|
|
59
81
|
// in the schema because legacy v2 accounts (pre-renewal feature) don't have
|
|
60
82
|
// one; the renewal cron treats a missing envelope as "reauth required" and
|
|
@@ -109,6 +131,7 @@ export const channelsSchema = z
|
|
|
109
131
|
'discord-bot': discordBotChannelSchema.optional(),
|
|
110
132
|
github: githubChannelSchema.optional(),
|
|
111
133
|
'telegram-bot': telegramBotChannelSchema.optional(),
|
|
134
|
+
line: lineChannelBlockSchema.optional(),
|
|
112
135
|
kakaotalk: kakaoChannelBlockSchema.optional(),
|
|
113
136
|
})
|
|
114
137
|
.catchall(z.unknown())
|
|
@@ -130,6 +153,8 @@ export type Channels = z.infer<typeof channelsSchema>
|
|
|
130
153
|
export type GithubPatAuthBlock = z.infer<typeof githubPatAuthSchema>
|
|
131
154
|
export type GithubAppAuthBlock = z.infer<typeof githubAppAuthSchema>
|
|
132
155
|
export type GithubSecretsBlock = z.infer<typeof githubChannelSchema>
|
|
156
|
+
export type LineAccountRecord = z.infer<typeof lineAccountRecordSchema>
|
|
157
|
+
export type LineChannelBlock = z.infer<typeof lineChannelBlockSchema>
|
|
133
158
|
export type KakaoAccountRecord = z.infer<typeof kakaoAccountRecordSchema>
|
|
134
159
|
export type PendingLoginRecord = z.infer<typeof kakaoPendingLoginRecordSchema>
|
|
135
160
|
export type KakaoChannelBlock = z.infer<typeof kakaoChannelBlockSchema>
|
package/src/secrets/storage.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import lockfile from 'proper-lockfile'
|
|
10
10
|
|
|
11
11
|
import { providerKeyDefaultEnv } from './defaults'
|
|
12
|
+
import { registerXaiOAuthProvider } from './oauth-xai'
|
|
12
13
|
import { resolveSecret, type Secret } from './resolve'
|
|
13
14
|
import {
|
|
14
15
|
type Channels,
|
|
@@ -374,6 +375,7 @@ export class SecretsBackend implements AuthStorageBackend {
|
|
|
374
375
|
}
|
|
375
376
|
|
|
376
377
|
export function createSecretsStoreForAgent(secretsPath: string): AuthStorage {
|
|
378
|
+
registerXaiOAuthProvider()
|
|
377
379
|
return AuthStorageImpl.fromStorage(new SecretsBackend(secretsPath))
|
|
378
380
|
}
|
|
379
381
|
|
package/src/server/index.ts
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
} from '@/agent/todo/continuation-wiring'
|
|
29
29
|
import { SUBAGENT_OUTPUT_TOOL_NAME } from '@/agent/tools/subagent-output'
|
|
30
30
|
import type { ChannelRouter } from '@/channels/router'
|
|
31
|
-
import { aggregateCronList, type CronListEntry, loadCron } from '@/cron'
|
|
31
|
+
import { aggregateCronList, type CronJob, type CronListEntry, loadCron } from '@/cron'
|
|
32
32
|
import type { McpManager } from '@/mcp'
|
|
33
33
|
import type { HookBus } from '@/plugin'
|
|
34
34
|
import type { BrokerWsData, ContainerBroker } from '@/portbroker'
|
|
@@ -75,6 +75,9 @@ export type ServerOptions = {
|
|
|
75
75
|
mcpManager?: McpManager
|
|
76
76
|
agentDir?: string
|
|
77
77
|
pluginRuntime?: PluginRuntime
|
|
78
|
+
// Durable cron fire-progress lookup so `cron list` marks count-exhausted jobs
|
|
79
|
+
// as retired instead of showing a stale future fire time. Omit in tests/dev.
|
|
80
|
+
getFiredCount?: (job: CronJob) => number
|
|
78
81
|
containerName?: string
|
|
79
82
|
runtimeVersion?: string
|
|
80
83
|
tuiToken?: string
|
|
@@ -252,6 +255,7 @@ export function createServer({
|
|
|
252
255
|
mcpManager,
|
|
253
256
|
agentDir,
|
|
254
257
|
pluginRuntime,
|
|
258
|
+
getFiredCount,
|
|
255
259
|
containerName,
|
|
256
260
|
runtimeVersion,
|
|
257
261
|
tuiToken,
|
|
@@ -716,7 +720,7 @@ export function createServer({
|
|
|
716
720
|
}
|
|
717
721
|
|
|
718
722
|
if (msg.type === 'cron_list') {
|
|
719
|
-
await handleCronList(ws, msg.requestId, pluginRuntime, agentDir)
|
|
723
|
+
await handleCronList(ws, msg.requestId, pluginRuntime, agentDir, getFiredCount)
|
|
720
724
|
return
|
|
721
725
|
}
|
|
722
726
|
|
|
@@ -1186,6 +1190,7 @@ async function handleCronList(
|
|
|
1186
1190
|
requestId: string,
|
|
1187
1191
|
pluginRuntime: PluginRuntime | undefined,
|
|
1188
1192
|
agentDir: string | undefined,
|
|
1193
|
+
getFiredCount?: (job: CronJob) => number,
|
|
1189
1194
|
): Promise<void> {
|
|
1190
1195
|
if (agentDir === undefined) {
|
|
1191
1196
|
send(ws, { type: 'cron_list_result', requestId, result: { ok: false, reason: 'agentDir not configured' } })
|
|
@@ -1208,7 +1213,12 @@ async function handleCronList(
|
|
|
1208
1213
|
const userJobs = loadResult.file?.jobs ?? []
|
|
1209
1214
|
const pluginJobs = snapshot?.registry.cronJobs ?? []
|
|
1210
1215
|
const nowMs = Date.now()
|
|
1211
|
-
const entries = aggregateCronList({
|
|
1216
|
+
const entries = aggregateCronList({
|
|
1217
|
+
userJobs,
|
|
1218
|
+
pluginJobs,
|
|
1219
|
+
now: nowMs,
|
|
1220
|
+
...(getFiredCount !== undefined ? { firedCount: getFiredCount } : {}),
|
|
1221
|
+
})
|
|
1212
1222
|
send(ws, {
|
|
1213
1223
|
type: 'cron_list_result',
|
|
1214
1224
|
requestId,
|
|
@@ -1541,9 +1551,12 @@ function toPayload(entry: CronListEntry): CronListEntryPayload {
|
|
|
1541
1551
|
id: entry.id,
|
|
1542
1552
|
source,
|
|
1543
1553
|
kind: entry.kind,
|
|
1544
|
-
schedule: entry.schedule,
|
|
1545
1554
|
enabled: entry.enabled,
|
|
1546
1555
|
nextFireMs: entry.nextFireMs,
|
|
1556
|
+
...(entry.schedule !== undefined ? { schedule: entry.schedule } : {}),
|
|
1557
|
+
...(entry.at !== undefined ? { at: entry.at } : {}),
|
|
1558
|
+
...(entry.until !== undefined ? { until: entry.until } : {}),
|
|
1559
|
+
...(entry.count !== undefined ? { count: entry.count } : {}),
|
|
1547
1560
|
...(entry.timezone !== undefined ? { timezone: entry.timezone } : {}),
|
|
1548
1561
|
...(entry.scheduledByRole !== undefined ? { scheduledByRole: entry.scheduledByRole } : {}),
|
|
1549
1562
|
...(entry.scheduleError !== undefined ? { scheduleError: entry.scheduleError } : {}),
|
package/src/shared/protocol.ts
CHANGED
|
@@ -165,7 +165,10 @@ export type CronListEntryPayload = {
|
|
|
165
165
|
id: string
|
|
166
166
|
source: CronListSourcePayload
|
|
167
167
|
kind: 'prompt' | 'exec' | 'handler'
|
|
168
|
-
schedule
|
|
168
|
+
schedule?: string
|
|
169
|
+
at?: string
|
|
170
|
+
until?: string
|
|
171
|
+
count?: number
|
|
169
172
|
timezone?: string
|
|
170
173
|
enabled: boolean
|
|
171
174
|
scheduledByRole?: string
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: typeclaw-channel-line
|
|
3
|
+
description: Use this skill BEFORE every `channel_reply` or `channel_send` call whose adapter is `line`. LINE renders messages as plain text — `**bold**`, `## headings`, `| tables |`, fenced code blocks, and other markdown all appear literally. There is no `@mention` syntax, no message threads, no replies-with-quote, and no outbound attachments or stickers. Inbound non-text content (images, stickers, files) is not fetchable through this adapter. Read this skill before composing anything on LINE.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# typeclaw-channel-line
|
|
7
|
+
|
|
8
|
+
You are speaking through the `line` channel adapter. The adapter wraps `agent-messenger`'s LineClient/LineListener, which speaks LINE's protocol as a registered sub-device of a real user account.
|
|
9
|
+
|
|
10
|
+
This means **you are messaging as a person, not as a bot.** Other participants see your account's name and profile photo. Match their tone — the people you're talking with assume they're talking to a human.
|
|
11
|
+
|
|
12
|
+
## What LINE does NOT support
|
|
13
|
+
|
|
14
|
+
LINE renders messages as plain text — it has no rich-text formatting. **Write plain text from the start.** The adapter strips common markdown as a safety net before sending (so an accidental `**bold**` won't leak literal asterisks), but treat that as a last-resort guard, not a license to write markdown: the strip removes _markers_, it cannot make formatting-dependent layouts like tables readable. Compose for a plain-text surface and you control the result.
|
|
15
|
+
|
|
16
|
+
Specifically, do not rely on any of the following — write the plain-text equivalent yourself:
|
|
17
|
+
|
|
18
|
+
- **Bold / italic / strikethrough** — emphasize with word choice, not `**asterisks**`.
|
|
19
|
+
- **Headings** — `# H1`, `## H2`, `### H3` carry no visual weight here. Lead with the point.
|
|
20
|
+
- **Tables** — the stripper cannot rescue a pipe-delimited table. Use bullet lists or short prose.
|
|
21
|
+
- **Code fences** — for short snippets, paste the code inline as plain text. For long snippets, summarize and offer to send it another way.
|
|
22
|
+
- **Inline code** — just write `foo`, no backticks.
|
|
23
|
+
- **Links with display text** — send the bare URL on its own line; the LINE client auto-links it. A `[label](url)` that slips through is reduced to `label (url)`, but a bare URL reads cleaner.
|
|
24
|
+
- **Mentions** — there is no `@user` syntax the protocol surfaces. Address people by name in the message body.
|
|
25
|
+
- **Threads / replies-with-quote** — every message is a top-level chat post. There is no per-message reply UI.
|
|
26
|
+
- **Outbound attachments / stickers** — the adapter sends text only. If the user asks you to send a file, image, or sticker, acknowledge the limit and offer text (e.g. paste a link to the file instead).
|
|
27
|
+
|
|
28
|
+
## What LINE DOES support
|
|
29
|
+
|
|
30
|
+
- Plain UTF-8 text. Emoji are fine.
|
|
31
|
+
- URLs auto-linkify in the client. Send them bare — `https://example.com/foo`, no markdown wrapping.
|
|
32
|
+
- Newlines render as line breaks. Use `\n\n` to space paragraphs.
|
|
33
|
+
|
|
34
|
+
## Inbound content
|
|
35
|
+
|
|
36
|
+
Inbound messages are text. LINE may deliver non-text content (images, stickers, files); the adapter surfaces only the text portion and you cannot fetch the bytes through this adapter. If a message arrives with no text, there is nothing for you to act on — do not invent attachment ids.
|
|
37
|
+
|
|
38
|
+
## Chats
|
|
39
|
+
|
|
40
|
+
LINE chats fall into three workspace buckets:
|
|
41
|
+
|
|
42
|
+
- `@line-dm` — a 1:1 direct message.
|
|
43
|
+
- `@line-group` — a group or room (multi-party invite chat).
|
|
44
|
+
- `@line-square` — an OpenChat-style public community. Treat these as the most public surface; be conservative about what you say.
|
|
45
|
+
|
|
46
|
+
Engagement on group and square chats is alias-only (there is no @-mention): you are woken when someone uses one of your configured aliases, replies in a way the engagement layer tracks, or in a DM. In a 1:1 DM every message engages.
|