typeclaw 0.9.2 → 0.11.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.
Files changed (76) hide show
  1. package/package.json +2 -2
  2. package/src/agent/index.ts +46 -11
  3. package/src/agent/restart-handoff/index.ts +91 -0
  4. package/src/agent/restart-handoff/paths.ts +11 -0
  5. package/src/agent/session-origin.ts +30 -10
  6. package/src/agent/subagent-completion-reminder.ts +4 -2
  7. package/src/agent/system-prompt.ts +1 -1
  8. package/src/agent/tools/restart.ts +42 -1
  9. package/src/agent/tools/skip-response.ts +157 -0
  10. package/src/bundled-plugins/memory/README.md +18 -2
  11. package/src/bundled-plugins/memory/index.ts +108 -6
  12. package/src/bundled-plugins/memory/memory-logger.ts +33 -24
  13. package/src/bundled-plugins/security/index.ts +19 -17
  14. package/src/bundled-plugins/security/permissions.ts +9 -8
  15. package/src/bundled-plugins/security/policies/cron-promotion.ts +26 -9
  16. package/src/bundled-plugins/security/policies/git-exfil.ts +23 -15
  17. package/src/bundled-plugins/security/policies/prompt-injection.ts +1 -1
  18. package/src/bundled-plugins/security/policies/role-promotion.ts +25 -18
  19. package/src/channels/adapters/github/auth-app.ts +53 -9
  20. package/src/channels/adapters/github/auth-pat.ts +4 -1
  21. package/src/channels/adapters/github/auth.ts +10 -0
  22. package/src/channels/adapters/github/event-permissions.ts +83 -0
  23. package/src/channels/adapters/github/inbound.ts +126 -1
  24. package/src/channels/adapters/github/index.ts +60 -66
  25. package/src/channels/adapters/github/outbound.ts +65 -17
  26. package/src/channels/adapters/github/permission-guidance.ts +169 -0
  27. package/src/channels/adapters/github/team-membership.ts +56 -0
  28. package/src/channels/router.ts +313 -10
  29. package/src/channels/schema.ts +22 -0
  30. package/src/channels/types.ts +1 -1
  31. package/src/cli/channel.ts +135 -38
  32. package/src/cli/cron.ts +1 -1
  33. package/src/cli/init.ts +133 -86
  34. package/src/cli/inspect-controller.ts +66 -0
  35. package/src/cli/inspect.ts +99 -14
  36. package/src/cli/role.ts +2 -2
  37. package/src/cli/run.ts +24 -5
  38. package/src/cli/tui.ts +34 -10
  39. package/src/cli/tunnel.ts +453 -14
  40. package/src/config/config.ts +35 -7
  41. package/src/config/providers.ts +82 -56
  42. package/src/cron/bridge.ts +25 -4
  43. package/src/hostd/daemon.ts +44 -24
  44. package/src/hostd/portbroker-manager.ts +19 -3
  45. package/src/init/dockerfile.ts +52 -0
  46. package/src/init/env-file.ts +66 -0
  47. package/src/init/gitignore.ts +8 -0
  48. package/src/init/hatching.ts +32 -5
  49. package/src/init/index.ts +131 -39
  50. package/src/init/validate-api-key.ts +31 -0
  51. package/src/inspect/index.ts +47 -6
  52. package/src/inspect/loop.ts +31 -0
  53. package/src/inspect/replay.ts +15 -1
  54. package/src/permissions/builtins.ts +29 -21
  55. package/src/permissions/permissions.ts +32 -5
  56. package/src/role-claim/code.ts +9 -9
  57. package/src/role-claim/controller.ts +3 -2
  58. package/src/role-claim/match-rule.ts +14 -19
  59. package/src/role-claim/pending.ts +2 -2
  60. package/src/run/codex-fetch-observer.ts +377 -0
  61. package/src/run/index.ts +12 -2
  62. package/src/server/index.ts +59 -1
  63. package/src/shared/protocol.ts +1 -1
  64. package/src/skills/typeclaw-channel-github/SKILL.md +45 -1
  65. package/src/skills/typeclaw-codex-cli/SKILL.md +1 -1
  66. package/src/skills/typeclaw-codex-cli/references/auth-flow.md +14 -1
  67. package/src/skills/typeclaw-config/SKILL.md +7 -1
  68. package/src/skills/typeclaw-config/references/recommended-mounts.md +233 -0
  69. package/src/skills/typeclaw-permissions/SKILL.md +24 -18
  70. package/src/skills/typeclaw-tunnels/SKILL.md +33 -1
  71. package/src/tui/index.ts +17 -5
  72. package/src/tunnels/index.ts +1 -0
  73. package/src/tunnels/manager.ts +18 -0
  74. package/src/tunnels/providers/cloudflare-named.ts +224 -0
  75. package/src/tunnels/types.ts +17 -1
  76. package/typeclaw.schema.json +120 -7
@@ -1,11 +1,18 @@
1
1
  import type { OutboundCallback, OutboundMessage, SendResult } from '@/channels/types'
2
2
 
3
3
  import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
4
+ import {
5
+ buildOutboundPermissionGuidance,
6
+ type GithubAuthType,
7
+ isOutboundPermissionDenial,
8
+ type OutboundEndpointKind,
9
+ } from './permission-guidance'
4
10
 
5
11
  export type GithubOutboundLogger = { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void }
6
12
 
7
13
  export function createGithubOutboundCallback(deps: {
8
14
  token: () => Promise<string>
15
+ authType: GithubAuthType
9
16
  logger: GithubOutboundLogger
10
17
  fetchImpl?: typeof fetch
11
18
  }): OutboundCallback {
@@ -22,19 +29,35 @@ export function createGithubOutboundCallback(deps: {
22
29
  if (target === null) return { ok: false, error: `invalid GitHub chat: ${msg.chat}` }
23
30
 
24
31
  if (target.kind === 'discussion') {
25
- return await postDiscussionComment({ ...deps, fetchImpl, repo, discussionNumber: target.number, body })
32
+ return await postDiscussionComment({
33
+ ...deps,
34
+ fetchImpl,
35
+ repo,
36
+ discussionNumber: target.number,
37
+ body,
38
+ })
26
39
  }
27
40
 
28
- const endpoint =
29
- target.kind === 'pr' && msg.thread !== null && msg.thread !== undefined && msg.thread !== ''
30
- ? `${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/pulls/${target.number}/comments/${encodeURIComponent(msg.thread)}/replies`
31
- : `${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/issues/${target.number}/comments`
32
- return await postJson(fetchImpl, await deps.token(), endpoint, { body })
41
+ const isPrReviewReply = target.kind === 'pr' && msg.thread !== null && msg.thread !== undefined && msg.thread !== ''
42
+ const endpoint = isPrReviewReply
43
+ ? `${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/pulls/${target.number}/comments/${encodeURIComponent(msg.thread ?? '')}/replies`
44
+ : `${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/issues/${target.number}/comments`
45
+ return await postJson(
46
+ fetchImpl,
47
+ await deps.token(),
48
+ endpoint,
49
+ { body },
50
+ {
51
+ authType: deps.authType,
52
+ endpointKind: isPrReviewReply ? 'pr-review-reply' : 'issue-comment',
53
+ },
54
+ )
33
55
  }
34
56
  }
35
57
 
36
58
  async function postDiscussionComment(options: {
37
59
  token: () => Promise<string>
60
+ authType: GithubAuthType
38
61
  fetchImpl: typeof fetch
39
62
  repo: RepoRef
40
63
  discussionNumber: number
@@ -43,14 +66,21 @@ async function postDiscussionComment(options: {
43
66
  const discussionId = await fetchDiscussionId(options)
44
67
  if (!discussionId.ok) return discussionId
45
68
  const mutation = `mutation($discussionId:ID!,$body:String!){addDiscussionComment(input:{discussionId:$discussionId,body:$body}){comment{id}}}`
46
- return await postGraphql(options.fetchImpl, await options.token(), mutation, {
47
- discussionId: discussionId.id,
48
- body: options.body,
49
- })
69
+ return await postGraphql(
70
+ options.fetchImpl,
71
+ await options.token(),
72
+ mutation,
73
+ {
74
+ discussionId: discussionId.id,
75
+ body: options.body,
76
+ },
77
+ { authType: options.authType, endpointKind: 'discussion-comment' },
78
+ )
50
79
  }
51
80
 
52
81
  async function fetchDiscussionId(options: {
53
82
  token: () => Promise<string>
83
+ authType: GithubAuthType
54
84
  fetchImpl: typeof fetch
55
85
  repo: RepoRef
56
86
  discussionNumber: number
@@ -65,6 +95,7 @@ async function fetchDiscussionId(options: {
65
95
  name: options.repo.name,
66
96
  number: options.discussionNumber,
67
97
  },
98
+ { authType: options.authType, endpointKind: 'discussion-comment' },
68
99
  )
69
100
  if (!result.ok) return result
70
101
  const id = result.data.repository?.discussion?.id
@@ -76,8 +107,9 @@ async function postGraphql(
76
107
  token: string,
77
108
  query: string,
78
109
  variables: Record<string, unknown>,
110
+ guidance: { authType: GithubAuthType; endpointKind: OutboundEndpointKind },
79
111
  ): Promise<SendResult> {
80
- const result = await graphql(fetchImpl, token, query, variables)
112
+ const result = await graphql(fetchImpl, token, query, variables, guidance)
81
113
  return result.ok ? { ok: true } : { ok: false, error: result.error }
82
114
  }
83
115
 
@@ -86,6 +118,7 @@ async function graphql<T>(
86
118
  token: string,
87
119
  query: string,
88
120
  variables: Record<string, unknown>,
121
+ guidance: { authType: GithubAuthType; endpointKind: OutboundEndpointKind },
89
122
  ): Promise<{ ok: true; data: T } | { ok: false; error: string }> {
90
123
  try {
91
124
  const response = await fetchImpl(`${GITHUB_API_BASE}/graphql`, {
@@ -95,10 +128,15 @@ async function graphql<T>(
95
128
  })
96
129
  const raw = (await response.json()) as { data?: T; errors?: Array<{ message?: string }> }
97
130
  if (!response.ok || raw.errors !== undefined) {
98
- return {
99
- ok: false,
100
- error: raw.errors?.map((e) => e.message ?? 'unknown').join('; ') ?? `HTTP ${response.status}`,
101
- }
131
+ // GraphQL errors carry a permission-denial in their `errors[].type` =
132
+ // 'FORBIDDEN' or message text. Match on either the HTTP 403 (rare for
133
+ // GraphQL) or the literal denial string in any error message.
134
+ const message = raw.errors?.map((e) => e.message ?? 'unknown').join('; ') ?? `HTTP ${response.status}`
135
+ const baseError = response.ok ? message : `GitHub API ${response.status}: ${message}`
136
+ const decorated = isOutboundPermissionDenial(response.ok ? 403 : response.status, message)
137
+ ? `${baseError}${buildOutboundPermissionGuidance(guidance)}`
138
+ : baseError
139
+ return { ok: false, error: decorated }
102
140
  }
103
141
  if (raw.data === undefined) return { ok: false, error: 'GraphQL response missing data' }
104
142
  return { ok: true, data: raw.data }
@@ -107,7 +145,13 @@ async function graphql<T>(
107
145
  }
108
146
  }
109
147
 
110
- async function postJson(fetchImpl: typeof fetch, token: string, url: string, payload: unknown): Promise<SendResult> {
148
+ async function postJson(
149
+ fetchImpl: typeof fetch,
150
+ token: string,
151
+ url: string,
152
+ payload: unknown,
153
+ guidance: { authType: GithubAuthType; endpointKind: OutboundEndpointKind },
154
+ ): Promise<SendResult> {
111
155
  try {
112
156
  const response = await fetchImpl(url, {
113
157
  method: 'POST',
@@ -116,7 +160,11 @@ async function postJson(fetchImpl: typeof fetch, token: string, url: string, pay
116
160
  })
117
161
  if (response.ok) return { ok: true }
118
162
  const text = await response.text().catch(() => '')
119
- return { ok: false, error: `GitHub API ${response.status}${text !== '' ? `: ${text}` : ''}` }
163
+ const baseError = `GitHub API ${response.status}${text !== '' ? `: ${text}` : ''}`
164
+ const decorated = isOutboundPermissionDenial(response.status, text)
165
+ ? `${baseError}${buildOutboundPermissionGuidance(guidance)}`
166
+ : baseError
167
+ return { ok: false, error: decorated }
120
168
  } catch (err) {
121
169
  return { ok: false, error: describe(err) }
122
170
  }
@@ -0,0 +1,169 @@
1
+ import type { PermissionGap } from './event-permissions'
2
+
3
+ export type GithubAuthType = 'pat' | 'app'
4
+
5
+ // What kind of outbound API the adapter was trying to call when it got a
6
+ // permission-failure response. Each value maps to a distinct GitHub App
7
+ // permission family (and, for PATs, a distinct scope), so each surfaces a
8
+ // different remediation message.
9
+ export type OutboundEndpointKind = 'issue-comment' | 'pr-review-reply' | 'discussion-comment'
10
+
11
+ // Parses webhook-register errors of the shape `list hooks failed: <status> <body>`.
12
+ // Returns the status code when it matches the two shapes GitHub emits for
13
+ // missing access on the list-hooks endpoint:
14
+ // - 404 Not Found: the token cannot see the repo at all (private repo
15
+ // gated behind missing repository access — GitHub returns 404 instead of
16
+ // 403 to avoid leaking the existence of private repos).
17
+ // - 403 Forbidden: the token sees the repo but lacks webhook-management
18
+ // permission, OR is blocked by an org SSO/SAML authorization gate.
19
+ // Returns null for any other error (network, malformed slug, create-hook
20
+ // failures, etc.) so the guidance only fires on the actual symptom.
21
+ export function parseListHooksPermissionStatus(error: string): number | null {
22
+ const match = error.match(/^list hooks failed: (404|403)\b/)
23
+ if (match === null) return null
24
+ return Number(match[1])
25
+ }
26
+
27
+ // The labels below intentionally mirror github.com's current UI verbatim so a
28
+ // user can grep their settings page for the exact string. If GitHub renames
29
+ // any of these in a future redesign, update both here and the
30
+ // `permissionGuidance` tests in lifecycle.test.ts.
31
+ //
32
+ // Fine-grained PAT:
33
+ // Settings → Developer settings → Personal access tokens → Fine-grained tokens
34
+ // "Resource owner", "Repository access", "Repository permissions" → "Webhooks" → "Read and write", "Metadata" → "Read-only"
35
+ // GitHub App:
36
+ // Settings → Developer settings → GitHub Apps → <app> → Permissions & events
37
+ // "Repository permissions" → "Webhooks" → "Read and write"
38
+ // Install/configure on the org: <app settings> → Install App / Configure → "Repository access"
39
+ // Classic PAT (legacy, still supported by GitHub but we don't surface it in
40
+ // channel-add prompts):
41
+ // Settings → Developer settings → Personal access tokens (classic)
42
+ // Scope: "admin:repo_hook" (or full "repo" for private repositories)
43
+ export function buildPermissionGuidance(
44
+ authType: GithubAuthType,
45
+ failures: ReadonlyArray<{ repo: string; status: number }>,
46
+ ): string {
47
+ const repoList = failures.map((f) => `${f.repo} (${f.status})`).join(', ')
48
+ const lines: string[] = [
49
+ `[github] webhook setup needs more access for: ${repoList}.`,
50
+ ' - 404 from GitHub means the token cannot see the repo (GitHub hides private repos behind 404 instead of 403).',
51
+ ' - 403 means the token sees the repo but lacks webhook permission, or is blocked by org SAML/SSO.',
52
+ '',
53
+ ]
54
+ if (authType === 'pat') {
55
+ lines.push(
56
+ ' Fix (fine-grained personal access token):',
57
+ ' 1. Open https://github.com/settings/personal-access-tokens and edit the token TypeClaw is using.',
58
+ ' 2. Under "Resource owner", select the org that owns the failing repos (e.g. the org in the slug above).',
59
+ ' 3. Under "Repository access", choose "Only select repositories" and add every failing repo (or pick "All repositories").',
60
+ ' 4. Under "Repository permissions", set "Webhooks" to "Read and write" and "Metadata" to "Read-only".',
61
+ ' 5. Save. If the org enforces SAML SSO, click "Configure SSO" next to the token and authorize the org.',
62
+ '',
63
+ ' Or (classic personal access token): grant the "admin:repo_hook" scope (or "repo" for private repos),',
64
+ ' and on a SAML-protected org click "Authorize" next to the token.',
65
+ )
66
+ } else {
67
+ lines.push(
68
+ ' Fix (GitHub App):',
69
+ ' 1. Open https://github.com/settings/apps and edit the app TypeClaw is using.',
70
+ ' 2. Under "Permissions & events" → "Repository permissions", set "Webhooks" to "Read and write". Save.',
71
+ ' 3. From the app page, click "Install App" (or "Configure" if already installed) and select the org that owns the failing repos.',
72
+ ' 4. Under "Repository access", choose "Only select repositories" and add every failing repo (or pick "All repositories").',
73
+ ' 5. If the app permissions changed in step 2, install owners must accept the updated permissions from the install page before the new access takes effect.',
74
+ )
75
+ }
76
+ return lines.join('\n')
77
+ }
78
+
79
+ // Always GitHub App: PATs don't have per-installation permission grants
80
+ // (their access is gated by token scopes, surfaced by the existing 404/403
81
+ // flow in webhook-register).
82
+ export function buildAppPermissionPreflightGuidance(gaps: ReadonlyArray<PermissionGap>): string {
83
+ const lines = [
84
+ `[github] GitHub App installation is missing permissions for ${gaps.length} configured event ${gaps.length === 1 ? 'family' : 'families'}:`,
85
+ ]
86
+ for (const gap of gaps) {
87
+ const eventList = gap.events.join(', ')
88
+ const grantedLabel = gap.granted === null ? 'none' : gap.granted
89
+ const needLabel = gap.needsWrite ? 'Read and write' : 'Read-only'
90
+ lines.push(` - ${gap.uiLabel}: granted=${grantedLabel}, need=${needLabel} (covers: ${eventList})`)
91
+ }
92
+ lines.push(
93
+ '',
94
+ ' Fix:',
95
+ ' 1. Open https://github.com/settings/apps and edit the app TypeClaw is using.',
96
+ ' 2. Under "Permissions & events" → "Repository permissions", set each missing permission above to the listed level. Save.',
97
+ ' 3. Open the install page (Install App / Configure for the org) and accept the updated permissions request — the new access only takes effect after the install owner accepts.',
98
+ ' 4. If the org enforces SAML SSO, ensure the App is authorized for the org from the org settings → Third-party Apps page.',
99
+ '',
100
+ ' Webhooks already received will continue to deliver, but payload fields and reply attempts that require the missing permission will fail with 403 ("Resource not accessible by integration") until the install is reaccepted.',
101
+ )
102
+ return lines.join('\n')
103
+ }
104
+
105
+ // The label and (for App auth) the level appear verbatim on github.com so
106
+ // users can grep their settings page for the exact strings. The PAT scope
107
+ // names are GitHub's canonical token-scope identifiers, also verbatim.
108
+ const OUTBOUND_PERMISSION_FOR_KIND: Record<
109
+ OutboundEndpointKind,
110
+ { label: string; level: 'Read and write'; patScope: string; patFineGrained: string }
111
+ > = {
112
+ 'issue-comment': {
113
+ label: 'Issues',
114
+ level: 'Read and write',
115
+ patScope: 'repo (or public_repo for public repos)',
116
+ patFineGrained: 'Issues',
117
+ },
118
+ 'pr-review-reply': {
119
+ label: 'Pull requests',
120
+ level: 'Read and write',
121
+ patScope: 'repo',
122
+ patFineGrained: 'Pull requests',
123
+ },
124
+ 'discussion-comment': {
125
+ label: 'Discussions',
126
+ level: 'Read and write',
127
+ patScope: 'repo',
128
+ patFineGrained: 'Discussions',
129
+ },
130
+ }
131
+
132
+ // Decorate an outbound-API failure with the precise github.com permission a
133
+ // user needs to enable. Called only on the 403 + "Resource not accessible by
134
+ // integration" combination — other 403s (org SSO, suspended install) need
135
+ // different remediation and would be mis-described here.
136
+ export function buildOutboundPermissionGuidance(options: {
137
+ authType: GithubAuthType
138
+ endpointKind: OutboundEndpointKind
139
+ }): string {
140
+ const perm = OUTBOUND_PERMISSION_FOR_KIND[options.endpointKind]
141
+ if (options.authType === 'app') {
142
+ return [
143
+ '',
144
+ ` Fix (GitHub App): the App needs "${perm.label}" → "${perm.level}".`,
145
+ ' 1. Open https://github.com/settings/apps and edit the app TypeClaw is using.',
146
+ ` 2. Under "Permissions & events" → "Repository permissions", set "${perm.label}" to "${perm.level}". Save.`,
147
+ ' 3. Open the install page (Install App / Configure for the org) and accept the updated permissions request — the new access only takes effect after the install owner accepts.',
148
+ ].join('\n')
149
+ }
150
+ return [
151
+ '',
152
+ ` Fix (fine-grained personal access token): grant "${perm.patFineGrained}" → "Read and write" on the failing repo.`,
153
+ ' 1. Open https://github.com/settings/personal-access-tokens and edit the token TypeClaw is using.',
154
+ ` 2. Under "Repository permissions", set "${perm.patFineGrained}" to "Read and write". Save.`,
155
+ ` 3. If the org enforces SAML SSO, click "Configure SSO" next to the token and authorize the org.`,
156
+ '',
157
+ ` Or (classic personal access token): grant the "${perm.patScope}" scope; SAML-protected orgs additionally need "Authorize" next to the token.`,
158
+ ].join('\n')
159
+ }
160
+
161
+ // 403 with this exact body is GitHub's signal that the call would succeed
162
+ // with the right permissions. Other 403 bodies (e.g. "OAuth token… needs to
163
+ // be authorized for this organization", suspended installation) need
164
+ // different remediation, so the decoration matcher is intentionally narrow.
165
+ const INTEGRATION_PERMISSION_DENIAL = 'Resource not accessible by integration'
166
+
167
+ export function isOutboundPermissionDenial(status: number, body: string): boolean {
168
+ return status === 403 && body.includes(INTEGRATION_PERMISSION_DENIAL)
169
+ }
@@ -0,0 +1,56 @@
1
+ // Best-effort "is the bot a member of this team?" lookup, used to gate
2
+ // `pull_request.review_requested` inbounds when GitHub assigns a *team*
3
+ // rather than a user as the reviewer.
4
+ //
5
+ // Cached per (org, slug, login) for the adapter's lifetime: team membership
6
+ // changes rarely, and a stale cache costs us one missed review (the next
7
+ // request rebuilds it). Errors fall closed (return false): we'd rather drop
8
+ // a real review request than wake the agent on a team the bot isn't in.
9
+
10
+ const ACTIVE_MEMBERSHIP_STATE = 'active'
11
+
12
+ export type TeamMembershipChecker = (input: { org: string; slug: string; login: string }) => Promise<boolean>
13
+
14
+ export function createTeamMembershipChecker(options: {
15
+ token: () => Promise<string>
16
+ fetchImpl?: typeof fetch
17
+ }): TeamMembershipChecker {
18
+ const fetchImpl = options.fetchImpl ?? fetch
19
+ const cache = new Map<string, boolean>()
20
+
21
+ return async ({ org, slug, login }) => {
22
+ const key = `${org}/${slug}#${login}`
23
+ const cached = cache.get(key)
24
+ if (cached !== undefined) return cached
25
+
26
+ const result = await lookup(fetchImpl, await options.token(), org, slug, login)
27
+ cache.set(key, result)
28
+ return result
29
+ }
30
+ }
31
+
32
+ async function lookup(
33
+ fetchImpl: typeof fetch,
34
+ token: string,
35
+ org: string,
36
+ slug: string,
37
+ login: string,
38
+ ): Promise<boolean> {
39
+ const url = `https://api.github.com/orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(slug)}/memberships/${encodeURIComponent(login)}`
40
+ let res: Response
41
+ try {
42
+ res = await fetchImpl(url, {
43
+ headers: {
44
+ accept: 'application/vnd.github+json',
45
+ authorization: `Bearer ${token}`,
46
+ 'x-github-api-version': '2022-11-28',
47
+ },
48
+ })
49
+ } catch {
50
+ return false
51
+ }
52
+ if (res.status === 404) return false
53
+ if (!res.ok) return false
54
+ const body = (await res.json().catch(() => null)) as { state?: unknown } | null
55
+ return typeof body?.state === 'string' && body.state === ACTIVE_MEMBERSHIP_STATE
56
+ }