@notis_ai/cli 0.2.13 → 0.2.15
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 +125 -21
- package/dist/agent-hooks/notis-agent-hook.mjs +19750 -0
- package/{skills → dist/base-skills}/notis-apps/SKILL.md +93 -58
- package/{skills → dist/base-skills}/notis-cli/SKILL.md +9 -9
- package/dist/base-skills/notis-query/SKILL.md +705 -0
- package/dist/skill-sync/index.js +1528 -0
- package/dist/skill-sync/index.js.map +7 -0
- package/package.json +5 -2
- package/skills/notis-apps/cli.md +44 -18
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
- package/skills/notis-onboarding/BRIEF.md +16 -0
- package/src/agent-hook-entry.js +5 -0
- package/src/cli.js +23 -14
- package/src/command-specs/agents.js +392 -0
- package/src/command-specs/apps.js +1090 -418
- package/src/command-specs/auth.js +63 -10
- package/src/command-specs/handover.js +374 -0
- package/src/command-specs/index.js +9 -0
- package/src/command-specs/onboarding.js +69 -5
- package/src/command-specs/skills.js +56 -0
- package/src/command-specs/tools.js +6 -0
- package/src/runtime/agent-memory-state.js +126 -0
- package/src/runtime/agent-setup.js +383 -0
- package/src/runtime/app-dev-consumers.js +154 -0
- package/src/runtime/app-dev-host-lock.js +80 -0
- package/src/runtime/app-dev-roots.js +284 -0
- package/src/runtime/app-dev-server.js +229 -30
- package/src/runtime/app-dev-sessions.js +105 -145
- package/src/runtime/app-platform.js +1499 -161
- package/src/runtime/app-registry-scaffolds.js +367 -0
- package/src/runtime/base-skills.d.ts +20 -0
- package/src/runtime/base-skills.js +167 -0
- package/src/runtime/delegated-context.js +68 -0
- package/src/runtime/git.js +233 -0
- package/src/runtime/login-listener.js +15 -0
- package/src/runtime/oauth.js +1506 -141
- package/src/runtime/output.js +7 -0
- package/src/runtime/ports.js +16 -0
- package/src/runtime/profiles.js +0 -5
- package/src/runtime/skill-sync/cloud-client.ts +96 -0
- package/src/runtime/skill-sync/index.ts +644 -0
- package/src/runtime/skill-sync/local-scanner.ts +1046 -0
- package/src/runtime/skill-sync/symlink-manager.ts +383 -0
- package/src/runtime/skill-sync/sync-plan.ts +22 -0
- package/src/runtime/skill-sync/types.ts +103 -0
- package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
- package/src/runtime/store-screenshot.js +6 -1
- package/src/runtime/sync-skills.d.ts +37 -0
- package/src/runtime/sync-skills.js +215 -0
- package/src/runtime/transport.js +19 -2
- package/template/.harness/index.html.tmpl +116 -47
- package/template/app/layout.tsx +5 -1
- package/template/notis.config.ts +1 -0
- package/template/packages/sdk/package.json +2 -1
- package/template/packages/sdk/src/components/DocumentEditor.tsx +13 -3
- package/template/packages/sdk/src/components/MarkdownEditor.tsx +121 -0
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +37 -119
- package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +5 -1
- package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +1 -1
- package/template/packages/sdk/src/components/NotisSelectionBoundary.tsx +55 -0
- package/template/packages/sdk/src/components/ShortcutHints.tsx +56 -0
- package/template/packages/sdk/src/config.ts +71 -0
- package/template/packages/sdk/src/documents.ts +28 -1
- package/template/packages/sdk/src/hooks/useActiveResource.ts +19 -0
- package/template/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
- package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +709 -0
- package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/template/packages/sdk/src/hooks/useHandover.ts +78 -0
- package/template/packages/sdk/src/hooks/useMultiSelect.ts +44 -482
- package/template/packages/sdk/src/hooks/useTool.ts +5 -4
- package/template/packages/sdk/src/index.ts +64 -2
- package/template/packages/sdk/src/interactions/actions.ts +46 -0
- package/template/packages/sdk/src/interactions/shortcuts.tsx +628 -0
- package/template/packages/sdk/src/interactions.ts +41 -0
- package/template/packages/sdk/src/provider.tsx +2 -1
- package/template/packages/sdk/src/runtime.ts +219 -3
- package/dist/scaffolds/notis-database/CHANGELOG.md +0 -5
- package/dist/scaffolds/notis-database/app/globals.css +0 -44
- package/dist/scaffolds/notis-database/app/layout.tsx +0 -6
- package/dist/scaffolds/notis-database/app/page.tsx +0 -1088
- package/dist/scaffolds/notis-database/components/ui/badge.tsx +0 -28
- package/dist/scaffolds/notis-database/components/ui/button.tsx +0 -53
- package/dist/scaffolds/notis-database/components/ui/card.tsx +0 -56
- package/dist/scaffolds/notis-database/components/ui/table.tsx +0 -120
- package/dist/scaffolds/notis-database/components.json +0 -20
- package/dist/scaffolds/notis-database/index.html +0 -12
- package/dist/scaffolds/notis-database/lib/types.ts +0 -132
- package/dist/scaffolds/notis-database/lib/utils.ts +0 -6
- package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-4.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-5.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-fixtures.json +0 -1839
- package/dist/scaffolds/notis-database/notis.config.ts +0 -73
- package/dist/scaffolds/notis-database/package-lock.json +0 -3935
- package/dist/scaffolds/notis-database/package.json +0 -32
- package/dist/scaffolds/notis-database/packages/sdk/package.json +0 -36
- package/dist/scaffolds/notis-database/packages/sdk/src/components/DocumentEditor.tsx +0 -93
- package/dist/scaffolds/notis-database/packages/sdk/src/components/Markdown.tsx +0 -60
- package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
- package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
- package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
- package/dist/scaffolds/notis-database/packages/sdk/src/config.ts +0 -196
- package/dist/scaffolds/notis-database/packages/sdk/src/documents.ts +0 -229
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useBackend.ts +0 -41
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocument.ts +0 -78
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocuments.ts +0 -121
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useNotis.ts +0 -34
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTool.ts +0 -64
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTools.ts +0 -56
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
- package/dist/scaffolds/notis-database/packages/sdk/src/index.ts +0 -83
- package/dist/scaffolds/notis-database/packages/sdk/src/provider.tsx +0 -43
- package/dist/scaffolds/notis-database/packages/sdk/src/runtime.ts +0 -220
- package/dist/scaffolds/notis-database/packages/sdk/src/styles.css +0 -186
- package/dist/scaffolds/notis-database/packages/sdk/src/ui.ts +0 -15
- package/dist/scaffolds/notis-database/packages/sdk/src/vite.ts +0 -56
- package/dist/scaffolds/notis-database/packages/sdk/tsconfig.json +0 -15
- package/dist/scaffolds/notis-database/postcss.config.mjs +0 -8
- package/dist/scaffolds/notis-database/src/dev-main.tsx +0 -23
- package/dist/scaffolds/notis-database/src/mock-runtime.ts +0 -561
- package/dist/scaffolds/notis-database/tailwind.config.ts +0 -59
- package/dist/scaffolds/notis-database/tsconfig.json +0 -23
- package/dist/scaffolds/notis-database/vite.config.ts +0 -22
- package/dist/scaffolds/notis-journal/CHANGELOG.md +0 -29
- package/dist/scaffolds/notis-journal/app/globals.css +0 -37
- package/dist/scaffolds/notis-journal/app/insights/page.tsx +0 -513
- package/dist/scaffolds/notis-journal/app/journal-core.tsx +0 -362
- package/dist/scaffolds/notis-journal/app/journal-ui.tsx +0 -337
- package/dist/scaffolds/notis-journal/app/layout.tsx +0 -6
- package/dist/scaffolds/notis-journal/app/page.tsx +0 -486
- package/dist/scaffolds/notis-journal/components/ui/badge.tsx +0 -28
- package/dist/scaffolds/notis-journal/components/ui/button.tsx +0 -53
- package/dist/scaffolds/notis-journal/components/ui/card.tsx +0 -56
- package/dist/scaffolds/notis-journal/components.json +0 -20
- package/dist/scaffolds/notis-journal/index.html +0 -12
- package/dist/scaffolds/notis-journal/lib/utils.ts +0 -6
- package/dist/scaffolds/notis-journal/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-4.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-5.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-6.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-fixtures.json +0 -132
- package/dist/scaffolds/notis-journal/notis.config.ts +0 -93
- package/dist/scaffolds/notis-journal/package-lock.json +0 -4615
- package/dist/scaffolds/notis-journal/package.json +0 -34
- package/dist/scaffolds/notis-journal/packages/sdk/package.json +0 -36
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/DocumentEditor.tsx +0 -93
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/Markdown.tsx +0 -60
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
- package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +0 -196
- package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +0 -229
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useBackend.ts +0 -41
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocument.ts +0 -78
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocuments.ts +0 -121
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotis.ts +0 -34
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTool.ts +0 -64
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTools.ts +0 -56
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
- package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +0 -83
- package/dist/scaffolds/notis-journal/packages/sdk/src/provider.tsx +0 -43
- package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +0 -220
- package/dist/scaffolds/notis-journal/packages/sdk/src/styles.css +0 -186
- package/dist/scaffolds/notis-journal/packages/sdk/src/ui.ts +0 -15
- package/dist/scaffolds/notis-journal/packages/sdk/src/vite.ts +0 -56
- package/dist/scaffolds/notis-journal/packages/sdk/tsconfig.json +0 -15
- package/dist/scaffolds/notis-journal/postcss.config.mjs +0 -8
- package/dist/scaffolds/notis-journal/skills/journal-onboarding/SKILL.md +0 -120
- package/dist/scaffolds/notis-journal/src/dev-main.tsx +0 -58
- package/dist/scaffolds/notis-journal/src/mock-runtime.ts +0 -197
- package/dist/scaffolds/notis-journal/tailwind.config.ts +0 -58
- package/dist/scaffolds/notis-journal/tsconfig.json +0 -23
- package/dist/scaffolds/notis-journal/vite.config.ts +0 -10
- package/dist/scaffolds/notis-notes/CHANGELOG.md +0 -5
- package/dist/scaffolds/notis-notes/app/globals.css +0 -3
- package/dist/scaffolds/notis-notes/app/layout.tsx +0 -6
- package/dist/scaffolds/notis-notes/app/page.tsx +0 -1943
- package/dist/scaffolds/notis-notes/app/phosphor-icons.ts +0 -596
- package/dist/scaffolds/notis-notes/components/ui/badge.tsx +0 -28
- package/dist/scaffolds/notis-notes/components/ui/button.tsx +0 -53
- package/dist/scaffolds/notis-notes/components/ui/card.tsx +0 -56
- package/dist/scaffolds/notis-notes/components.json +0 -20
- package/dist/scaffolds/notis-notes/lib/utils.ts +0 -6
- package/dist/scaffolds/notis-notes/lib/visible-properties.ts +0 -144
- package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-4.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-5.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-fixtures.json +0 -752
- package/dist/scaffolds/notis-notes/notis.config.ts +0 -80
- package/dist/scaffolds/notis-notes/package-lock.json +0 -4636
- package/dist/scaffolds/notis-notes/package.json +0 -35
- package/dist/scaffolds/notis-notes/packages/sdk/package.json +0 -36
- package/dist/scaffolds/notis-notes/packages/sdk/src/components/DocumentEditor.tsx +0 -93
- package/dist/scaffolds/notis-notes/packages/sdk/src/components/Markdown.tsx +0 -60
- package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
- package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
- package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
- package/dist/scaffolds/notis-notes/packages/sdk/src/config.ts +0 -196
- package/dist/scaffolds/notis-notes/packages/sdk/src/documents.ts +0 -229
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useBackend.ts +0 -41
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocument.ts +0 -78
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocuments.ts +0 -121
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotis.ts +0 -34
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTool.ts +0 -64
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTools.ts +0 -56
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
- package/dist/scaffolds/notis-notes/packages/sdk/src/index.ts +0 -83
- package/dist/scaffolds/notis-notes/packages/sdk/src/provider.tsx +0 -43
- package/dist/scaffolds/notis-notes/packages/sdk/src/runtime.ts +0 -220
- package/dist/scaffolds/notis-notes/packages/sdk/src/styles.css +0 -186
- package/dist/scaffolds/notis-notes/packages/sdk/src/ui.ts +0 -15
- package/dist/scaffolds/notis-notes/packages/sdk/src/vite.ts +0 -56
- package/dist/scaffolds/notis-notes/packages/sdk/tsconfig.json +0 -15
- package/dist/scaffolds/notis-notes/postcss.config.mjs +0 -8
- package/dist/scaffolds/notis-notes/tailwind.config.ts +0 -58
- package/dist/scaffolds/notis-notes/tsconfig.json +0 -23
- package/dist/scaffolds/notis-notes/vite.config.ts +0 -10
- package/dist/scaffolds/notis-random/CHANGELOG.md +0 -15
- package/dist/scaffolds/notis-random/README.md +0 -33
- package/dist/scaffolds/notis-random/app/globals.css +0 -11
- package/dist/scaffolds/notis-random/app/history/page.tsx +0 -67
- package/dist/scaffolds/notis-random/app/layout.tsx +0 -7
- package/dist/scaffolds/notis-random/app/page.tsx +0 -289
- package/dist/scaffolds/notis-random/components/ui/button.tsx +0 -50
- package/dist/scaffolds/notis-random/components/ui/card.tsx +0 -16
- package/dist/scaffolds/notis-random/components/ui/input.tsx +0 -23
- package/dist/scaffolds/notis-random/components.json +0 -20
- package/dist/scaffolds/notis-random/index.html +0 -12
- package/dist/scaffolds/notis-random/lib/notis-tools.ts +0 -128
- package/dist/scaffolds/notis-random/lib/rng.ts +0 -202
- package/dist/scaffolds/notis-random/lib/roll-record.ts +0 -189
- package/dist/scaffolds/notis-random/lib/utils.ts +0 -25
- package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-4.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-5.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-fixtures.json +0 -753
- package/dist/scaffolds/notis-random/notis.config.ts +0 -86
- package/dist/scaffolds/notis-random/package-lock.json +0 -4513
- package/dist/scaffolds/notis-random/package.json +0 -36
- package/dist/scaffolds/notis-random/packages/sdk/package.json +0 -36
- package/dist/scaffolds/notis-random/packages/sdk/src/components/DocumentEditor.tsx +0 -93
- package/dist/scaffolds/notis-random/packages/sdk/src/components/Markdown.tsx +0 -60
- package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
- package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
- package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
- package/dist/scaffolds/notis-random/packages/sdk/src/config.ts +0 -196
- package/dist/scaffolds/notis-random/packages/sdk/src/documents.ts +0 -229
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useBackend.ts +0 -41
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocument.ts +0 -78
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocuments.ts +0 -121
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotis.ts +0 -34
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTool.ts +0 -64
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTools.ts +0 -56
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
- package/dist/scaffolds/notis-random/packages/sdk/src/index.ts +0 -83
- package/dist/scaffolds/notis-random/packages/sdk/src/provider.tsx +0 -43
- package/dist/scaffolds/notis-random/packages/sdk/src/runtime.ts +0 -220
- package/dist/scaffolds/notis-random/packages/sdk/src/styles.css +0 -186
- package/dist/scaffolds/notis-random/packages/sdk/src/ui.ts +0 -15
- package/dist/scaffolds/notis-random/packages/sdk/src/vite.ts +0 -56
- package/dist/scaffolds/notis-random/packages/sdk/tsconfig.json +0 -15
- package/dist/scaffolds/notis-random/postcss.config.mjs +0 -6
- package/dist/scaffolds/notis-random/src/dev-main.tsx +0 -70
- package/dist/scaffolds/notis-random/src/mock-runtime.ts +0 -129
- package/dist/scaffolds/notis-random/tailwind.config.ts +0 -50
- package/dist/scaffolds/notis-random/tsconfig.json +0 -23
- package/dist/scaffolds/notis-random/vite.config.ts +0 -11
- package/dist/scaffolds.json +0 -49
- package/template/metadata/screenshot-1.png +0 -0
- package/template/metadata/screenshot-2.png +0 -0
- package/template/metadata/screenshot-3.png +0 -0
package/src/runtime/oauth.js
CHANGED
|
@@ -5,7 +5,9 @@ import {
|
|
|
5
5
|
} from 'node:crypto';
|
|
6
6
|
import {
|
|
7
7
|
mkdirSync,
|
|
8
|
+
readdirSync,
|
|
8
9
|
readFileSync,
|
|
10
|
+
renameSync,
|
|
9
11
|
rmdirSync,
|
|
10
12
|
rmSync,
|
|
11
13
|
statSync,
|
|
@@ -13,11 +15,12 @@ import {
|
|
|
13
15
|
} from 'node:fs';
|
|
14
16
|
import { createServer } from 'node:http';
|
|
15
17
|
import { homedir } from 'node:os';
|
|
16
|
-
import { dirname, join } from 'node:path';
|
|
17
|
-
import {
|
|
18
|
+
import { basename, dirname, join } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
18
21
|
import { createInterface } from 'node:readline/promises';
|
|
19
22
|
|
|
20
|
-
import { CliError, EXIT_CODES } from './errors.js';
|
|
23
|
+
import { CliError, EXIT_CODES, usageError } from './errors.js';
|
|
21
24
|
import { getAuthRecovery, quoteShellArgument } from './auth-recovery.js';
|
|
22
25
|
import {
|
|
23
26
|
channelFromProfile,
|
|
@@ -49,6 +52,7 @@ const DEFAULT_REFRESH_EXPIRES_IN = 30 * 24 * 60 * 60;
|
|
|
49
52
|
const PENDING_LOGIN_TTL_SECONDS = 30 * 60;
|
|
50
53
|
const OAUTH_HTTP_TIMEOUT_MS = 10_000;
|
|
51
54
|
const RESPONSE_FLUSH_GRACE_MS = 2_000;
|
|
55
|
+
const MAX_NODE_TIMER_MS = 2_147_483_647;
|
|
52
56
|
|
|
53
57
|
function oauthError(code, message, hints = null, details = {}) {
|
|
54
58
|
return new CliError({
|
|
@@ -515,17 +519,17 @@ export async function createLoopbackReceiver({
|
|
|
515
519
|
endResponse(response, callbackHtml('Already used', 'This authorization callback was already handled.'));
|
|
516
520
|
return;
|
|
517
521
|
}
|
|
518
|
-
consumed = true;
|
|
519
|
-
|
|
520
522
|
const returnedState = url.searchParams.get('state') || '';
|
|
521
523
|
const code = url.searchParams.get('code');
|
|
522
524
|
const error = url.searchParams.get('error');
|
|
523
525
|
if (!stateMatches(state, returnedState)) {
|
|
524
526
|
response.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
525
527
|
endResponse(response, callbackHtml('Authorization failed', 'The callback state did not match.'));
|
|
526
|
-
rejectCode(oauthError('oauth_state_mismatch', 'The OAuth callback state did not match.'));
|
|
527
528
|
return;
|
|
528
529
|
}
|
|
530
|
+
// A stray or malicious request must not burn the one legitimate callback.
|
|
531
|
+
// Only a request carrying this authorization's state consumes the receiver.
|
|
532
|
+
consumed = true;
|
|
529
533
|
if (error || !code) {
|
|
530
534
|
const description = url.searchParams.get('error_description') || 'Authorization was not completed.';
|
|
531
535
|
response.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
@@ -606,15 +610,20 @@ export async function createLoopbackReceiver({
|
|
|
606
610
|
endResponse(pendingResponse, connectedCallbackHtml({ portalOrigin }));
|
|
607
611
|
pendingResponse = null;
|
|
608
612
|
},
|
|
609
|
-
fail: () => {
|
|
613
|
+
fail: ({ detached = false } = {}) => {
|
|
610
614
|
if (!pendingResponse || pendingResponse.writableEnded) return;
|
|
611
615
|
pendingResponse.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
612
616
|
endResponse(pendingResponse, callbackHtml(
|
|
613
617
|
'Authorization failed',
|
|
614
|
-
|
|
618
|
+
detached
|
|
619
|
+
? 'The CLI could not finish signing in. Start sign in again from your agent or terminal.'
|
|
620
|
+
: 'The CLI could not finish signing in. Return to the terminal for details.',
|
|
615
621
|
));
|
|
616
622
|
pendingResponse = null;
|
|
617
623
|
},
|
|
624
|
+
cancel: () => {
|
|
625
|
+
rejectCode(oauthError('oauth_listener_cancelled', 'This browser authorization is no longer active.'));
|
|
626
|
+
},
|
|
618
627
|
close,
|
|
619
628
|
};
|
|
620
629
|
}
|
|
@@ -775,7 +784,7 @@ function savePendingAuthorization(runtime, pending) {
|
|
|
775
784
|
writeFileSync(file, JSON.stringify(pending, null, 2), { mode: 0o600 });
|
|
776
785
|
}
|
|
777
786
|
|
|
778
|
-
function readPendingAuthorization(runtime) {
|
|
787
|
+
function readPendingAuthorization(runtime, { includeExpired = false } = {}) {
|
|
779
788
|
for (const file of [
|
|
780
789
|
pendingAuthorizationFile(runtime),
|
|
781
790
|
legacyPendingAuthorizationFile(runtime),
|
|
@@ -787,7 +796,7 @@ function readPendingAuthorization(runtime) {
|
|
|
787
796
|
continue;
|
|
788
797
|
}
|
|
789
798
|
if (!pending?.verifier || !pending?.redirect_uri) continue;
|
|
790
|
-
if (Number(pending.expires_at) <= Math.floor(Date.now() / 1000)) continue;
|
|
799
|
+
if (!includeExpired && Number(pending.expires_at) <= Math.floor(Date.now() / 1000)) continue;
|
|
791
800
|
if (pending.profile !== runtime.profileName) continue;
|
|
792
801
|
return { ...pending, pending_file: file };
|
|
793
802
|
}
|
|
@@ -802,6 +811,869 @@ function clearPendingAuthorization(runtime, file = pendingAuthorizationFile(runt
|
|
|
802
811
|
}
|
|
803
812
|
}
|
|
804
813
|
|
|
814
|
+
function clearPendingAuthorizations(runtime) {
|
|
815
|
+
clearPendingAuthorization(runtime);
|
|
816
|
+
const legacyFile = legacyPendingAuthorizationFile(runtime);
|
|
817
|
+
try {
|
|
818
|
+
const pending = JSON.parse(readFileSync(legacyFile, 'utf-8'));
|
|
819
|
+
if (pending?.profile === runtime.profileName) rmSync(legacyFile);
|
|
820
|
+
} catch {
|
|
821
|
+
// Missing, malformed, or owned by another profile.
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function pendingAuthorizationOwnedBy(pending, owner) {
|
|
826
|
+
return Boolean(
|
|
827
|
+
pending
|
|
828
|
+
&& pending.hand_off === owner.hand_off
|
|
829
|
+
&& pending.state === owner.state
|
|
830
|
+
&& pending.verifier === owner.verifier
|
|
831
|
+
&& pending.redirect_uri === owner.redirect_uri
|
|
832
|
+
&& pending.api_base === owner.api_base
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function clearPendingAuthorizationIfOwner(runtime, owner) {
|
|
837
|
+
// Cleanup must still remove an owner that expired at the same instant as a
|
|
838
|
+
// receiver timeout; ordinary reads continue to ignore expired grants.
|
|
839
|
+
const pending = readPendingAuthorization(runtime, { includeExpired: true });
|
|
840
|
+
if (!pendingAuthorizationOwnedBy(pending, owner)) return false;
|
|
841
|
+
clearPendingAuthorization(runtime, pending.pending_file);
|
|
842
|
+
return true;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async function publishForegroundAuthorization(runtime, authorization) {
|
|
846
|
+
const globalLock = await acquireListenerGlobalLock();
|
|
847
|
+
let lockDir = null;
|
|
848
|
+
try {
|
|
849
|
+
lockDir = await acquireListenerStartLock(runtime);
|
|
850
|
+
stopPendingListener(runtime);
|
|
851
|
+
clearPendingAuthorizations(runtime);
|
|
852
|
+
savePendingAuthorization(runtime, authorization);
|
|
853
|
+
} finally {
|
|
854
|
+
releaseListenerStartLock(lockDir);
|
|
855
|
+
releaseListenerGlobalLock(globalLock);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const LISTENER_SCRIPT = fileURLToPath(new URL('./login-listener.js', import.meta.url));
|
|
860
|
+
const LISTENER_SCRIPT_NAME = 'login-listener.js';
|
|
861
|
+
// How long the parent waits for the detached child to bind and report its port.
|
|
862
|
+
// Only a bind, so this is generous; exceeding it means the child is not coming.
|
|
863
|
+
const LISTENER_HANDSHAKE_TIMEOUT_MS = 10_000;
|
|
864
|
+
const LISTENER_START_LOCK_STALE_MS = LISTENER_HANDSHAKE_TIMEOUT_MS * 2;
|
|
865
|
+
const LISTENER_GLOBAL_LOCK_HEARTBEAT_MS = 5_000;
|
|
866
|
+
const LISTENER_CANCELLATION_POLL_MS = 100;
|
|
867
|
+
const LISTENER_IDENTITY_PROBE_TIMEOUT_MS = 2_000;
|
|
868
|
+
|
|
869
|
+
const LISTENER_CHILD_ENV_KEYS = [
|
|
870
|
+
'HOME',
|
|
871
|
+
'USERPROFILE',
|
|
872
|
+
'APPDATA',
|
|
873
|
+
'LOCALAPPDATA',
|
|
874
|
+
'TMPDIR',
|
|
875
|
+
'TEMP',
|
|
876
|
+
'TMP',
|
|
877
|
+
'SystemRoot',
|
|
878
|
+
'WINDIR',
|
|
879
|
+
'ComSpec',
|
|
880
|
+
'PATH',
|
|
881
|
+
'Path',
|
|
882
|
+
'PATHEXT',
|
|
883
|
+
'NODE_EXTRA_CA_CERTS',
|
|
884
|
+
'SSL_CERT_FILE',
|
|
885
|
+
'SSL_CERT_DIR',
|
|
886
|
+
];
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* A detached listener outlives the command that launched it. Give it only the
|
|
890
|
+
* OS/runtime values needed to start Node and find its private config, never the
|
|
891
|
+
* caller's unrelated API keys, service credentials, or inherited NOTIS_JWT.
|
|
892
|
+
*/
|
|
893
|
+
export function listenerChildEnvironment(env = process.env) {
|
|
894
|
+
const childEnv = {};
|
|
895
|
+
for (const key of LISTENER_CHILD_ENV_KEYS) {
|
|
896
|
+
if (typeof env[key] === 'string' && env[key]) childEnv[key] = env[key];
|
|
897
|
+
}
|
|
898
|
+
childEnv.NOTIS_CLI_CONFIG_FILE = resolveConfigFile();
|
|
899
|
+
return childEnv;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function listenerStateFile(runtime) {
|
|
903
|
+
const profileKey = createHash('sha256')
|
|
904
|
+
.update(String(runtime.profileName || 'default'))
|
|
905
|
+
.digest('hex')
|
|
906
|
+
.slice(0, 16);
|
|
907
|
+
return `${resolveConfigFile()}.login-listener.${profileKey}`;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function listenerStartLockDir(runtime) {
|
|
911
|
+
return `${listenerStateFile(runtime)}.lock`;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function listenerGlobalLockDir() {
|
|
915
|
+
return `${resolveConfigFile()}.oauth-listener-global.lock`;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function listenerGlobalLockOwnerFile(lockDir) {
|
|
919
|
+
return join(lockDir, 'owner.json');
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function readListenerGlobalLockOwner(lockDir) {
|
|
923
|
+
try {
|
|
924
|
+
return JSON.parse(readFileSync(listenerGlobalLockOwnerFile(lockDir), 'utf-8'));
|
|
925
|
+
} catch {
|
|
926
|
+
return null;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function writeListenerGlobalLockOwner(lock) {
|
|
931
|
+
const current = readListenerGlobalLockOwner(lock.lockDir);
|
|
932
|
+
if (current && current.owner_token !== lock.ownerToken) return false;
|
|
933
|
+
writeFileSync(listenerGlobalLockOwnerFile(lock.lockDir), JSON.stringify({
|
|
934
|
+
owner_token: lock.ownerToken,
|
|
935
|
+
owner_pid: process.pid,
|
|
936
|
+
updated_at: Date.now(),
|
|
937
|
+
}), { mode: 0o600 });
|
|
938
|
+
return true;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function listenerGlobalLockIsStale(lockDir, staleMs) {
|
|
942
|
+
try {
|
|
943
|
+
return Date.now() - statSync(listenerGlobalLockOwnerFile(lockDir)).mtimeMs > staleMs;
|
|
944
|
+
} catch {
|
|
945
|
+
try {
|
|
946
|
+
return Date.now() - statSync(lockDir).mtimeMs > staleMs;
|
|
947
|
+
} catch {
|
|
948
|
+
return false;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function retireStaleListenerGlobalLock(lockDir) {
|
|
954
|
+
const retiredDir = `${lockDir}.stale-${process.pid}-${base64url(randomBytes(8))}`;
|
|
955
|
+
try {
|
|
956
|
+
renameSync(lockDir, retiredDir);
|
|
957
|
+
} catch {
|
|
958
|
+
return false;
|
|
959
|
+
}
|
|
960
|
+
rmSync(retiredDir, { recursive: true, force: true });
|
|
961
|
+
return true;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
async function acquireListenerStartLock(runtime) {
|
|
965
|
+
const lockDir = listenerStartLockDir(runtime);
|
|
966
|
+
mkdirSync(dirname(lockDir), { recursive: true });
|
|
967
|
+
const deadline = Date.now() + LISTENER_START_LOCK_STALE_MS * 2;
|
|
968
|
+
for (;;) {
|
|
969
|
+
try {
|
|
970
|
+
mkdirSync(lockDir);
|
|
971
|
+
return lockDir;
|
|
972
|
+
} catch (error) {
|
|
973
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
974
|
+
try {
|
|
975
|
+
if (Date.now() - statSync(lockDir).mtimeMs > LISTENER_START_LOCK_STALE_MS) {
|
|
976
|
+
rmdirSync(lockDir);
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
} catch {
|
|
980
|
+
// The owner may have released the lock between stat and removal.
|
|
981
|
+
}
|
|
982
|
+
if (Date.now() >= deadline) {
|
|
983
|
+
throw oauthError(
|
|
984
|
+
'oauth_listener_lock_timeout',
|
|
985
|
+
'Timed out waiting for another CLI process to start browser authorization.',
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function releaseListenerStartLock(lockDir) {
|
|
994
|
+
if (!lockDir) return;
|
|
995
|
+
try {
|
|
996
|
+
rmdirSync(lockDir);
|
|
997
|
+
} catch {
|
|
998
|
+
// A crashed owner or stale-lock cleanup may already have removed it.
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
export async function acquireListenerGlobalLock({
|
|
1003
|
+
staleMs = LISTENER_START_LOCK_STALE_MS,
|
|
1004
|
+
waitMs = LISTENER_START_LOCK_STALE_MS * 2,
|
|
1005
|
+
heartbeatMs = LISTENER_GLOBAL_LOCK_HEARTBEAT_MS,
|
|
1006
|
+
} = {}) {
|
|
1007
|
+
const lockDir = listenerGlobalLockDir();
|
|
1008
|
+
mkdirSync(dirname(lockDir), { recursive: true });
|
|
1009
|
+
const deadline = Date.now() + waitMs;
|
|
1010
|
+
for (;;) {
|
|
1011
|
+
try {
|
|
1012
|
+
mkdirSync(lockDir);
|
|
1013
|
+
const lock = {
|
|
1014
|
+
lockDir,
|
|
1015
|
+
ownerToken: base64url(randomBytes(24)),
|
|
1016
|
+
heartbeat: null,
|
|
1017
|
+
};
|
|
1018
|
+
writeListenerGlobalLockOwner(lock);
|
|
1019
|
+
lock.heartbeat = setInterval(() => {
|
|
1020
|
+
try {
|
|
1021
|
+
writeListenerGlobalLockOwner(lock);
|
|
1022
|
+
} catch {
|
|
1023
|
+
// A stale-lock recovery may have retired this directory. Ownership-
|
|
1024
|
+
// qualified release below must not disturb the successor.
|
|
1025
|
+
}
|
|
1026
|
+
}, heartbeatMs);
|
|
1027
|
+
lock.heartbeat.unref?.();
|
|
1028
|
+
return lock;
|
|
1029
|
+
} catch (error) {
|
|
1030
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
1031
|
+
if (
|
|
1032
|
+
listenerGlobalLockIsStale(lockDir, staleMs)
|
|
1033
|
+
&& retireStaleListenerGlobalLock(lockDir)
|
|
1034
|
+
) {
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
if (Date.now() >= deadline) {
|
|
1038
|
+
throw oauthError(
|
|
1039
|
+
'oauth_listener_global_lock_timeout',
|
|
1040
|
+
'Timed out waiting for another CLI process to finish OAuth account changes.',
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
export function releaseListenerGlobalLock(lock) {
|
|
1049
|
+
if (!lock) return;
|
|
1050
|
+
clearInterval(lock.heartbeat);
|
|
1051
|
+
const owner = readListenerGlobalLockOwner(lock.lockDir);
|
|
1052
|
+
if (owner?.owner_token !== lock.ownerToken) return;
|
|
1053
|
+
const releasedDir = `${lock.lockDir}.released-${process.pid}-${lock.ownerToken}`;
|
|
1054
|
+
try {
|
|
1055
|
+
renameSync(lock.lockDir, releasedDir);
|
|
1056
|
+
} catch {
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
const movedOwner = readListenerGlobalLockOwner(releasedDir);
|
|
1060
|
+
if (movedOwner?.owner_token !== lock.ownerToken) {
|
|
1061
|
+
try { renameSync(releasedDir, lock.lockDir); } catch { /* successor already owns the path */ }
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
rmSync(releasedDir, { recursive: true, force: true });
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function saveListenerState(runtime, state) {
|
|
1068
|
+
const file = listenerStateFile(runtime);
|
|
1069
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
1070
|
+
writeFileSync(file, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function readListenerState(runtime) {
|
|
1074
|
+
try {
|
|
1075
|
+
const state = JSON.parse(readFileSync(listenerStateFile(runtime), 'utf-8'));
|
|
1076
|
+
return state?.profile === runtime.profileName ? state : null;
|
|
1077
|
+
} catch {
|
|
1078
|
+
return null;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function listenerStateAllowsPersistence(runtime, ownerPid) {
|
|
1083
|
+
const state = readListenerState(runtime);
|
|
1084
|
+
return Boolean(
|
|
1085
|
+
state
|
|
1086
|
+
&& Number(state.pid) === Number(ownerPid)
|
|
1087
|
+
&& state.cancelled !== true
|
|
1088
|
+
&& Number(state.expires_at) > Math.floor(Date.now() / 1000),
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Drop the listener record, optionally only when it still describes `ownerPid`.
|
|
1094
|
+
*
|
|
1095
|
+
* A child that times out must not delete a record a newer child already wrote:
|
|
1096
|
+
* losing it orphans the live listener, because every later stop and reuse finds
|
|
1097
|
+
* the profile through this file.
|
|
1098
|
+
*/
|
|
1099
|
+
export function clearListenerState(runtime, { ownerPid = null } = {}) {
|
|
1100
|
+
if (ownerPid !== null) {
|
|
1101
|
+
try {
|
|
1102
|
+
const state = JSON.parse(readFileSync(listenerStateFile(runtime), 'utf-8'));
|
|
1103
|
+
if (Number(state?.pid) !== Number(ownerPid)) return;
|
|
1104
|
+
} catch {
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
try {
|
|
1109
|
+
rmSync(listenerStateFile(runtime));
|
|
1110
|
+
} catch {
|
|
1111
|
+
// Nothing to clear.
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* A listener from an earlier run that is still waiting for the same browser.
|
|
1117
|
+
*
|
|
1118
|
+
* Re-running `notis start` is routine — an agent does it to check whether the
|
|
1119
|
+
* user has finished — and each run would otherwise strand another detached
|
|
1120
|
+
* process holding another port. Handing back the authorization URL the user was
|
|
1121
|
+
* already given is also the only answer that stays true: the earlier URL is the
|
|
1122
|
+
* one whose PKCE verifier the live listener holds.
|
|
1123
|
+
*/
|
|
1124
|
+
function readLiveListener(runtime, { sameApiBase = true } = {}) {
|
|
1125
|
+
const state = readListenerState(runtime);
|
|
1126
|
+
if (!state?.pid || !state?.authorize_url) return null;
|
|
1127
|
+
if (state.cancelled === true) return null;
|
|
1128
|
+
// Reuse needs the endpoint to match, because a grant belongs to the API that
|
|
1129
|
+
// issued it. Stopping one does not: a listener for any endpoint is still
|
|
1130
|
+
// about to write this profile.
|
|
1131
|
+
if (sameApiBase && state.api_base !== runtime.apiBase) return null;
|
|
1132
|
+
if (Number(state.expires_at) <= Math.floor(Date.now() / 1000)) return null;
|
|
1133
|
+
if (!listenerProcessIsAlive(state.pid, {
|
|
1134
|
+
expectedIdentity: state.identity_token,
|
|
1135
|
+
expectedScriptPath: state.listener_script,
|
|
1136
|
+
})) return null;
|
|
1137
|
+
return state;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* Is this pid still *our* listener?
|
|
1142
|
+
*
|
|
1143
|
+
* A bare `kill(pid, 0)` only proves some process holds the number. State files
|
|
1144
|
+
* outlive reboots and SIGKILLs, and pids are recycled, so that test eventually
|
|
1145
|
+
* reports a stranger as the listener — which would hand out an authorize URL
|
|
1146
|
+
* whose loopback port is dead, and let `logout` signal an unrelated process.
|
|
1147
|
+
* Matching the command line costs one `ps` on a rare path and rules both out.
|
|
1148
|
+
*/
|
|
1149
|
+
export function listenerProcessIsAlive(
|
|
1150
|
+
pid,
|
|
1151
|
+
{
|
|
1152
|
+
platform = process.platform,
|
|
1153
|
+
run = execFileSync,
|
|
1154
|
+
signal = process.kill.bind(process),
|
|
1155
|
+
expectedIdentity = null,
|
|
1156
|
+
expectedScriptPath = null,
|
|
1157
|
+
} = {},
|
|
1158
|
+
) {
|
|
1159
|
+
const numericPid = Number(pid);
|
|
1160
|
+
if (!Number.isSafeInteger(numericPid) || numericPid <= 0) return false;
|
|
1161
|
+
try {
|
|
1162
|
+
// Signal 0 tests for a live process without touching it.
|
|
1163
|
+
signal(numericPid, 0);
|
|
1164
|
+
} catch {
|
|
1165
|
+
return false;
|
|
1166
|
+
}
|
|
1167
|
+
// tasklist's verbose view does not contain a process command line, so it can
|
|
1168
|
+
// never identify the script behind node.exe. CIM exposes the actual command
|
|
1169
|
+
// line and is available through Windows PowerShell and modern PowerShell.
|
|
1170
|
+
const windowsCommand = `(Get-CimInstance Win32_Process -Filter "ProcessId = ${numericPid}").CommandLine`;
|
|
1171
|
+
const probes = platform === 'win32'
|
|
1172
|
+
? [
|
|
1173
|
+
['powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', windowsCommand]],
|
|
1174
|
+
['pwsh.exe', ['-NoProfile', '-NonInteractive', '-Command', windowsCommand]],
|
|
1175
|
+
]
|
|
1176
|
+
: [['ps', ['-o', 'command=', '-p', String(numericPid)]]];
|
|
1177
|
+
for (const [command, args] of probes) {
|
|
1178
|
+
try {
|
|
1179
|
+
const output = run(command, args, {
|
|
1180
|
+
encoding: 'utf-8',
|
|
1181
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1182
|
+
timeout: LISTENER_IDENTITY_PROBE_TIMEOUT_MS,
|
|
1183
|
+
});
|
|
1184
|
+
return Boolean(
|
|
1185
|
+
expectedIdentity
|
|
1186
|
+
&& expectedScriptPath
|
|
1187
|
+
&& output.includes(expectedScriptPath)
|
|
1188
|
+
&& output.includes(LISTENER_SCRIPT_NAME)
|
|
1189
|
+
&& output.includes(expectedIdentity),
|
|
1190
|
+
);
|
|
1191
|
+
} catch {
|
|
1192
|
+
// Try the next probe.
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
// Nothing here can tell this pid apart from a stranger that inherited the
|
|
1196
|
+
// number. Fail closed: the cost is a listener we stop reusing, against
|
|
1197
|
+
// signalling an unrelated process, which is the failure that cannot be undone.
|
|
1198
|
+
return false;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* End an authorization that is still in flight for this profile.
|
|
1203
|
+
*
|
|
1204
|
+
* Best effort by design: the listener may already have exited, and failing to
|
|
1205
|
+
* reach it is never a reason to fail the command that asked for this.
|
|
1206
|
+
*/
|
|
1207
|
+
export function stopPendingListener(
|
|
1208
|
+
runtime,
|
|
1209
|
+
{
|
|
1210
|
+
platform = process.platform,
|
|
1211
|
+
signal = process.kill.bind(process),
|
|
1212
|
+
} = {},
|
|
1213
|
+
) {
|
|
1214
|
+
const state = readListenerState(runtime);
|
|
1215
|
+
const live = readLiveListener(runtime, { sameApiBase: false });
|
|
1216
|
+
if (live) {
|
|
1217
|
+
// Persist cancellation before notifying the child. POSIX can deliver a
|
|
1218
|
+
// catchable SIGTERM, but Windows terminates Node immediately for that
|
|
1219
|
+
// signal. On Windows the child observes this tombstone through its polling
|
|
1220
|
+
// channel and stays alive long enough to revoke an in-flight exchange.
|
|
1221
|
+
saveListenerState(runtime, { ...live, cancelled: true });
|
|
1222
|
+
if (platform === 'win32') return;
|
|
1223
|
+
try {
|
|
1224
|
+
signal(live.pid, 'SIGTERM');
|
|
1225
|
+
clearListenerState(runtime, { ownerPid: live.pid });
|
|
1226
|
+
} catch {
|
|
1227
|
+
// Already gone, or owned by another user. Keep the tombstone so a child
|
|
1228
|
+
// that is still alive cannot persist this authorization.
|
|
1229
|
+
}
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
if (
|
|
1233
|
+
state?.pid
|
|
1234
|
+
&& Number(state.expires_at) > Math.floor(Date.now() / 1000)
|
|
1235
|
+
) {
|
|
1236
|
+
// Signal 0 may prove the PID exists while ps/CIM cannot prove it is ours.
|
|
1237
|
+
// Never signal that process, but keep a cancellation tombstone the child
|
|
1238
|
+
// must observe under the start lock before it can persist credentials.
|
|
1239
|
+
saveListenerState(runtime, { ...state, cancelled: true });
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
clearListenerState(runtime);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
async function stopPendingListenerAfterStart(runtime) {
|
|
1246
|
+
const lockDir = await acquireListenerStartLock(runtime);
|
|
1247
|
+
try {
|
|
1248
|
+
stopPendingListener(runtime);
|
|
1249
|
+
} finally {
|
|
1250
|
+
releaseListenerStartLock(lockDir);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
async function clearListenerStateAfterStart(runtime, { ownerPid }) {
|
|
1255
|
+
const lockDir = await acquireListenerStartLock(runtime);
|
|
1256
|
+
try {
|
|
1257
|
+
clearListenerState(runtime, { ownerPid });
|
|
1258
|
+
} finally {
|
|
1259
|
+
releaseListenerStartLock(lockDir);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* Hand the loopback callback to a process that outlives this command.
|
|
1265
|
+
*
|
|
1266
|
+
* An agent reads a command's output only once the command exits, so a login
|
|
1267
|
+
* that waited in-process would hold the authorization URL hostage inside a
|
|
1268
|
+
* command that cannot finish until the user opens the URL they were never
|
|
1269
|
+
* shown. Detaching breaks that deadlock: this process prints the URL and exits
|
|
1270
|
+
* while the child keeps the listener, so an agent-driven signup gets the same
|
|
1271
|
+
* no-copy browser hand-off a human at a terminal gets.
|
|
1272
|
+
*
|
|
1273
|
+
* Returns null rather than throwing when the child cannot be started or cannot
|
|
1274
|
+
* bind — a sandbox that forbids either is a reason to fall back to the code
|
|
1275
|
+
* flow, not to fail the login.
|
|
1276
|
+
*/
|
|
1277
|
+
async function startDetachedLoopbackListener(runtime, {
|
|
1278
|
+
metadata,
|
|
1279
|
+
verifier,
|
|
1280
|
+
state,
|
|
1281
|
+
scopes,
|
|
1282
|
+
timeoutMs,
|
|
1283
|
+
portalOrigin,
|
|
1284
|
+
}) {
|
|
1285
|
+
// A retry can start in another process before this child consumes its input.
|
|
1286
|
+
// Keep each verifier/state payload private to exactly one child.
|
|
1287
|
+
const identityToken = randomBytes(24).toString('base64url');
|
|
1288
|
+
const payloadFile = `${listenerStateFile(runtime)}.payload.${process.pid}.${randomBytes(8).toString('hex')}`;
|
|
1289
|
+
try {
|
|
1290
|
+
mkdirSync(dirname(payloadFile), { recursive: true });
|
|
1291
|
+
writeFileSync(payloadFile, JSON.stringify({
|
|
1292
|
+
profile: runtime.profileName,
|
|
1293
|
+
api_base: runtime.apiBase,
|
|
1294
|
+
verifier,
|
|
1295
|
+
state,
|
|
1296
|
+
scopes,
|
|
1297
|
+
timeout_ms: timeoutMs,
|
|
1298
|
+
portal_origin: portalOrigin,
|
|
1299
|
+
metadata,
|
|
1300
|
+
}), { mode: 0o600, flag: 'wx' });
|
|
1301
|
+
} catch {
|
|
1302
|
+
return null;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
let child;
|
|
1306
|
+
try {
|
|
1307
|
+
child = spawn(process.execPath, [LISTENER_SCRIPT, payloadFile, identityToken], {
|
|
1308
|
+
detached: true,
|
|
1309
|
+
windowsHide: true,
|
|
1310
|
+
// An IPC channel only so the child can report the port it bound. Every
|
|
1311
|
+
// other stream is dropped: the child must not write to a terminal the
|
|
1312
|
+
// parent no longer owns.
|
|
1313
|
+
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
|
1314
|
+
env: listenerChildEnvironment(),
|
|
1315
|
+
});
|
|
1316
|
+
} catch {
|
|
1317
|
+
try { rmSync(payloadFile); } catch { /* best effort */ }
|
|
1318
|
+
return null;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
const port = await new Promise((resolve) => {
|
|
1322
|
+
let settled = false;
|
|
1323
|
+
const finish = (value) => {
|
|
1324
|
+
if (settled) return;
|
|
1325
|
+
settled = true;
|
|
1326
|
+
clearTimeout(timer);
|
|
1327
|
+
child.off('message', onMessage);
|
|
1328
|
+
child.off('error', onFailure);
|
|
1329
|
+
child.off('exit', onFailure);
|
|
1330
|
+
resolve(value);
|
|
1331
|
+
};
|
|
1332
|
+
const onMessage = (message) => finish(Number(message?.port) || null);
|
|
1333
|
+
const onFailure = () => finish(null);
|
|
1334
|
+
const timer = setTimeout(() => finish(null), LISTENER_HANDSHAKE_TIMEOUT_MS);
|
|
1335
|
+
child.once('message', onMessage);
|
|
1336
|
+
child.once('error', onFailure);
|
|
1337
|
+
child.once('exit', onFailure);
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
if (!port) {
|
|
1341
|
+
try { child.kill(); } catch { /* already gone */ }
|
|
1342
|
+
try { rmSync(payloadFile); } catch { /* the child may have consumed it */ }
|
|
1343
|
+
return null;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// The child owns its lifetime from here. Disconnecting drops the only handle
|
|
1347
|
+
// keeping this process's event loop alive on its behalf.
|
|
1348
|
+
try { child.disconnect(); } catch { /* already disconnected */ }
|
|
1349
|
+
child.unref();
|
|
1350
|
+
return {
|
|
1351
|
+
pid: child.pid,
|
|
1352
|
+
port,
|
|
1353
|
+
redirectUri: `http://127.0.0.1:${port}/callback`,
|
|
1354
|
+
identityToken,
|
|
1355
|
+
scriptPath: LISTENER_SCRIPT,
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
async function revokeCancelledToken(metadata, tokenResponse, fetchImpl) {
|
|
1360
|
+
const token = tokenResponse?.refresh_token || tokenResponse?.access_token;
|
|
1361
|
+
if (!token || !metadata?.revocationEndpoint || !metadata?.clientId) return;
|
|
1362
|
+
try {
|
|
1363
|
+
await fetchJson(
|
|
1364
|
+
metadata.revocationEndpoint,
|
|
1365
|
+
{
|
|
1366
|
+
method: 'POST',
|
|
1367
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
1368
|
+
body: new URLSearchParams({ token, client_id: metadata.clientId }),
|
|
1369
|
+
},
|
|
1370
|
+
fetchImpl,
|
|
1371
|
+
);
|
|
1372
|
+
} catch {
|
|
1373
|
+
// The child still refuses local persistence. Revocation is compensating
|
|
1374
|
+
// cleanup and must not turn a cancelled browser page into a credential.
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
/**
|
|
1379
|
+
* The detached child's whole life: bind, report the port, wait, persist.
|
|
1380
|
+
*
|
|
1381
|
+
* Runs in its own process with no terminal, so nothing here may write to stdout
|
|
1382
|
+
* or throw past the top level — a crash would leave the user staring at a
|
|
1383
|
+
* browser page that never resolves.
|
|
1384
|
+
*/
|
|
1385
|
+
export async function runDetachedLoginListener(
|
|
1386
|
+
payloadFile,
|
|
1387
|
+
fetchImpl = fetch,
|
|
1388
|
+
identityToken = process.argv[3] || null,
|
|
1389
|
+
) {
|
|
1390
|
+
let payload;
|
|
1391
|
+
try {
|
|
1392
|
+
payload = JSON.parse(readFileSync(payloadFile, 'utf-8'));
|
|
1393
|
+
} catch {
|
|
1394
|
+
return 1;
|
|
1395
|
+
}
|
|
1396
|
+
// The verifier is a bearer secret for this authorization. It has been read;
|
|
1397
|
+
// it should not outlive the read.
|
|
1398
|
+
try { rmSync(payloadFile); } catch { /* best effort */ }
|
|
1399
|
+
|
|
1400
|
+
const runtime = { profileName: payload.profile, apiBase: payload.api_base };
|
|
1401
|
+
let receiver;
|
|
1402
|
+
try {
|
|
1403
|
+
receiver = await createLoopbackReceiver({
|
|
1404
|
+
state: payload.state,
|
|
1405
|
+
timeoutMs: Number(payload.timeout_ms) || DEFAULT_LOGIN_TIMEOUT_MS,
|
|
1406
|
+
portalOrigin: payload.portal_origin,
|
|
1407
|
+
});
|
|
1408
|
+
} catch {
|
|
1409
|
+
return 1;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
process.send?.({ port: receiver.port });
|
|
1413
|
+
|
|
1414
|
+
// Logout signals the child instead of killing it blindly. Before exchange,
|
|
1415
|
+
// cancellation closes the wait immediately. During exchange, the child stays
|
|
1416
|
+
// alive long enough to revoke any grant the server may already have issued.
|
|
1417
|
+
let terminationRequested = false;
|
|
1418
|
+
const requestTermination = () => {
|
|
1419
|
+
if (terminationRequested) return;
|
|
1420
|
+
terminationRequested = true;
|
|
1421
|
+
receiver.cancel();
|
|
1422
|
+
};
|
|
1423
|
+
process.once('SIGTERM', requestTermination);
|
|
1424
|
+
// Windows cannot deliver a catchable SIGTERM to another Node process. The
|
|
1425
|
+
// state file is therefore also a cross-platform cancellation channel. A
|
|
1426
|
+
// replacement listener changes the owner pid; logout marks this owner as
|
|
1427
|
+
// cancelled. Either transition must stop this child before it can persist.
|
|
1428
|
+
let ownPublicationObserved = false;
|
|
1429
|
+
const cancellationPoll = setInterval(() => {
|
|
1430
|
+
const state = readListenerState(runtime);
|
|
1431
|
+
if (!state) return;
|
|
1432
|
+
const stateBelongsToThisChild = Boolean(
|
|
1433
|
+
Number(state.pid) === Number(process.pid)
|
|
1434
|
+
&& (!identityToken || state.identity_token === identityToken),
|
|
1435
|
+
);
|
|
1436
|
+
// The parent publishes this child's ownership only after the child has
|
|
1437
|
+
// bound and reported its port. Until that publication is visible, an old
|
|
1438
|
+
// Windows/unverifiable cancellation tombstone still belongs to the
|
|
1439
|
+
// predecessor and must not make the replacement cancel itself.
|
|
1440
|
+
if (!ownPublicationObserved) {
|
|
1441
|
+
if (!stateBelongsToThisChild) return;
|
|
1442
|
+
ownPublicationObserved = true;
|
|
1443
|
+
}
|
|
1444
|
+
if (
|
|
1445
|
+
!stateBelongsToThisChild
|
|
1446
|
+
|| state.cancelled === true
|
|
1447
|
+
|| Number(state.expires_at) <= Math.floor(Date.now() / 1000)
|
|
1448
|
+
) {
|
|
1449
|
+
requestTermination();
|
|
1450
|
+
}
|
|
1451
|
+
}, LISTENER_CANCELLATION_POLL_MS);
|
|
1452
|
+
cancellationPoll.unref?.();
|
|
1453
|
+
|
|
1454
|
+
let tokenResponse = null;
|
|
1455
|
+
let tokenPersisted = false;
|
|
1456
|
+
try {
|
|
1457
|
+
const code = await receiver.waitForCode();
|
|
1458
|
+
// Keep replacement publication and logout serialized across the whole
|
|
1459
|
+
// exchange. Revoking an old refresh token revokes the CLI grant, not just
|
|
1460
|
+
// one token family, so compensation must complete before a successor can
|
|
1461
|
+
// exchange and persist under that same grant.
|
|
1462
|
+
const globalLock = await acquireListenerGlobalLock();
|
|
1463
|
+
let lockDir = null;
|
|
1464
|
+
try {
|
|
1465
|
+
lockDir = await acquireListenerStartLock(runtime);
|
|
1466
|
+
if (terminationRequested || !listenerStateAllowsPersistence(runtime, process.pid)) {
|
|
1467
|
+
throw oauthError(
|
|
1468
|
+
'oauth_listener_cancelled',
|
|
1469
|
+
'This browser authorization is no longer active.',
|
|
1470
|
+
);
|
|
1471
|
+
}
|
|
1472
|
+
tokenResponse = await exchangeCode(
|
|
1473
|
+
payload.metadata,
|
|
1474
|
+
{ code, redirectUri: receiver.redirectUri, verifier: payload.verifier },
|
|
1475
|
+
fetchImpl,
|
|
1476
|
+
);
|
|
1477
|
+
if (terminationRequested || !listenerStateAllowsPersistence(runtime, process.pid)) {
|
|
1478
|
+
throw oauthError(
|
|
1479
|
+
'oauth_listener_cancelled',
|
|
1480
|
+
'This browser authorization is no longer active.',
|
|
1481
|
+
);
|
|
1482
|
+
}
|
|
1483
|
+
persistOAuthTokenResponse(runtime, payload.metadata, tokenResponse);
|
|
1484
|
+
tokenPersisted = true;
|
|
1485
|
+
clearListenerState(runtime, { ownerPid: process.pid });
|
|
1486
|
+
receiver.complete();
|
|
1487
|
+
} catch (error) {
|
|
1488
|
+
if (tokenResponse && !tokenPersisted) {
|
|
1489
|
+
await revokeCancelledToken(payload.metadata, tokenResponse, fetchImpl);
|
|
1490
|
+
}
|
|
1491
|
+
throw error;
|
|
1492
|
+
} finally {
|
|
1493
|
+
releaseListenerStartLock(lockDir);
|
|
1494
|
+
releaseListenerGlobalLock(globalLock);
|
|
1495
|
+
}
|
|
1496
|
+
return 0;
|
|
1497
|
+
} catch {
|
|
1498
|
+
receiver.fail({ detached: true });
|
|
1499
|
+
return 1;
|
|
1500
|
+
} finally {
|
|
1501
|
+
clearInterval(cancellationPoll);
|
|
1502
|
+
process.off('SIGTERM', requestTermination);
|
|
1503
|
+
await receiver.close();
|
|
1504
|
+
try {
|
|
1505
|
+
await clearListenerStateAfterStart(runtime, { ownerPid: process.pid });
|
|
1506
|
+
} catch {
|
|
1507
|
+
// The child is exiting and can no longer authorize anything. A stale
|
|
1508
|
+
// owner-qualified record is safe for the next login to replace.
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/**
|
|
1514
|
+
* Would a `127.0.0.1` callback on this host reach the browser the user is in?
|
|
1515
|
+
*
|
|
1516
|
+
* Over SSH or inside a container it usually would not: the listener binds fine,
|
|
1517
|
+
* so nothing fails, and the user's own machine answers the callback URL with a
|
|
1518
|
+
* connection refused. The Portal code hand-off works from any browser, so an
|
|
1519
|
+
* uncertain answer has to resolve to `false` — the cost of choosing it wrongly
|
|
1520
|
+
* is one copied code, against a login that cannot be completed at all.
|
|
1521
|
+
*
|
|
1522
|
+
* `--mode browser` still forces the loopback for anyone who has forwarded the
|
|
1523
|
+
* port and knows better.
|
|
1524
|
+
*/
|
|
1525
|
+
export function loopbackReachesTheUsersBrowser(
|
|
1526
|
+
env = process.env,
|
|
1527
|
+
pathExists = (path) => {
|
|
1528
|
+
try {
|
|
1529
|
+
statSync(path);
|
|
1530
|
+
return true;
|
|
1531
|
+
} catch {
|
|
1532
|
+
return false;
|
|
1533
|
+
}
|
|
1534
|
+
},
|
|
1535
|
+
platform = process.platform,
|
|
1536
|
+
) {
|
|
1537
|
+
// Connection markers carry identity/address data, so their presence is the
|
|
1538
|
+
// signal. CI/provider flags are booleans encoded as strings; conventional
|
|
1539
|
+
// disabled values must not turn a local machine into a remote host merely
|
|
1540
|
+
// because non-empty strings are truthy in JavaScript.
|
|
1541
|
+
const remoteConnectionMarkers = [
|
|
1542
|
+
'SSH_CONNECTION',
|
|
1543
|
+
'SSH_CLIENT',
|
|
1544
|
+
'SSH_TTY',
|
|
1545
|
+
'JENKINS_URL',
|
|
1546
|
+
'CODEBUILD_BUILD_ID',
|
|
1547
|
+
];
|
|
1548
|
+
const remoteBooleanMarkers = [
|
|
1549
|
+
'CODESPACES',
|
|
1550
|
+
'REMOTE_CONTAINERS',
|
|
1551
|
+
'DEVCONTAINER',
|
|
1552
|
+
'CI',
|
|
1553
|
+
'GITHUB_ACTIONS',
|
|
1554
|
+
'GITLAB_CI',
|
|
1555
|
+
'BUILDKITE',
|
|
1556
|
+
'CIRCLECI',
|
|
1557
|
+
'TF_BUILD',
|
|
1558
|
+
'RENDER',
|
|
1559
|
+
'VERCEL',
|
|
1560
|
+
];
|
|
1561
|
+
const disabledFlagValues = new Set(['', '0', 'false', 'no', 'off']);
|
|
1562
|
+
const remoteFlagEnabled = (name) => {
|
|
1563
|
+
if (env[name] === undefined || env[name] === null) return false;
|
|
1564
|
+
return !disabledFlagValues.has(String(env[name]).trim().toLowerCase());
|
|
1565
|
+
};
|
|
1566
|
+
if (
|
|
1567
|
+
remoteConnectionMarkers.some((name) => Boolean(env[name]))
|
|
1568
|
+
|| remoteBooleanMarkers.some(remoteFlagEnabled)
|
|
1569
|
+
) return false;
|
|
1570
|
+
// Notis cloud agents run under this canonical root. Their browser belongs to
|
|
1571
|
+
// the user, not the Vercel VM, so a listener on the VM's loopback is unreachable.
|
|
1572
|
+
if (pathExists('/vercel/sandbox') || pathExists('/.dockerenv')) return false;
|
|
1573
|
+
// macOS and Windows browser launches are local unless one of the remote
|
|
1574
|
+
// markers above says otherwise. On Linux, require a graphical session:
|
|
1575
|
+
// marker-free cloud workers are commonly plain VMs where 127.0.0.1 belongs
|
|
1576
|
+
// to the worker rather than to the user's browser.
|
|
1577
|
+
if (platform === 'darwin' || platform === 'win32') return true;
|
|
1578
|
+
if (platform === 'linux') {
|
|
1579
|
+
return Boolean(env.DISPLAY || env.WAYLAND_DISPLAY || env.MIR_SOCKET);
|
|
1580
|
+
}
|
|
1581
|
+
return false;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
const LOGIN_MODES = new Set(['auto', 'browser', 'code']);
|
|
1585
|
+
|
|
1586
|
+
function requestedLoginMode(options) {
|
|
1587
|
+
const requested = options.mode ? String(options.mode).toLowerCase() : null;
|
|
1588
|
+
if (requested && !LOGIN_MODES.has(requested)) {
|
|
1589
|
+
throw usageError(
|
|
1590
|
+
`Unknown login mode "${requested}". Use auto, browser, or code.`,
|
|
1591
|
+
{ code: 'oauth_login_mode_invalid', mode: requested },
|
|
1592
|
+
);
|
|
1593
|
+
}
|
|
1594
|
+
return requested;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
function requestedAuthorizationTimeoutMs(options) {
|
|
1598
|
+
const hasSeconds = options.timeoutSeconds !== undefined && options.timeoutSeconds !== null;
|
|
1599
|
+
const hasMilliseconds = options.timeoutMs !== undefined && options.timeoutMs !== null;
|
|
1600
|
+
if (!hasSeconds && !hasMilliseconds) return null;
|
|
1601
|
+
const raw = hasSeconds ? options.timeoutSeconds : options.timeoutMs;
|
|
1602
|
+
const numeric = Number(raw);
|
|
1603
|
+
const milliseconds = hasSeconds ? numeric * 1000 : numeric;
|
|
1604
|
+
if (
|
|
1605
|
+
!Number.isFinite(numeric)
|
|
1606
|
+
|| numeric <= 0
|
|
1607
|
+
|| !Number.isInteger(numeric)
|
|
1608
|
+
|| !Number.isSafeInteger(milliseconds)
|
|
1609
|
+
|| milliseconds > MAX_NODE_TIMER_MS
|
|
1610
|
+
) {
|
|
1611
|
+
throw usageError(
|
|
1612
|
+
'--timeout-seconds must be a positive whole number within the supported timer range.',
|
|
1613
|
+
{ timeout_seconds: raw },
|
|
1614
|
+
);
|
|
1615
|
+
}
|
|
1616
|
+
return milliseconds;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
/**
|
|
1620
|
+
* Which hand-off the browser uses to return the authorization code.
|
|
1621
|
+
*
|
|
1622
|
+
* `auto`, the default, always tries the loopback hand-off first so nobody has
|
|
1623
|
+
* to copy a code. Whether this process can wait for the browser decides only
|
|
1624
|
+
* *who* holds the listener: a terminal login keeps it in-process, and a login
|
|
1625
|
+
* that has to return immediately hands it to a detached child. The Portal code
|
|
1626
|
+
* flow is the fallback when no listener can start or the user's browser cannot
|
|
1627
|
+
* safely reach this machine's loopback callback.
|
|
1628
|
+
*
|
|
1629
|
+
* `browser` additionally insists on waiting in-process, which is what a piped
|
|
1630
|
+
* but human-driven run (CI, `| tee`) wants. `code` forces the Portal flow for
|
|
1631
|
+
* an SSH session where no browser on this machine can reach 127.0.0.1.
|
|
1632
|
+
*/
|
|
1633
|
+
function resolveLoginMode(runtime, options) {
|
|
1634
|
+
const requested = requestedLoginMode(options);
|
|
1635
|
+
// --paste-code predates --mode and stays an alias so published commands and
|
|
1636
|
+
// documented recipes keep working unchanged.
|
|
1637
|
+
const mode = requested || (options.pasteCode ? 'code' : 'auto');
|
|
1638
|
+
const wantsNonBlocking = Boolean(
|
|
1639
|
+
runtime.agentMode
|
|
1640
|
+
|| ['json', 'yaml', 'ndjson'].includes(runtime.outputMode)
|
|
1641
|
+
|| runtime.nonInteractive,
|
|
1642
|
+
);
|
|
1643
|
+
|
|
1644
|
+
if (mode === 'browser' && runtime.agentMode) {
|
|
1645
|
+
throw oauthError(
|
|
1646
|
+
'oauth_login_mode_unavailable',
|
|
1647
|
+
'Agent mode cannot block on a browser callback: the authorization URL only reaches the user once this command exits.',
|
|
1648
|
+
[
|
|
1649
|
+
{
|
|
1650
|
+
command: 'notis login',
|
|
1651
|
+
reason: 'The default already hands the browser callback to a background listener, so no code is copied',
|
|
1652
|
+
},
|
|
1653
|
+
{ command: 'notis login --mode code', reason: 'Show a code the agent can hand to the user instead' },
|
|
1654
|
+
],
|
|
1655
|
+
);
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
const loopbackReachable = loopbackReachesTheUsersBrowser(
|
|
1659
|
+
runtime.hostEnvironment ?? process.env,
|
|
1660
|
+
undefined,
|
|
1661
|
+
runtime.hostPlatform ?? process.platform,
|
|
1662
|
+
);
|
|
1663
|
+
return {
|
|
1664
|
+
automaticMode: mode === 'auto',
|
|
1665
|
+
wantsNonBlocking,
|
|
1666
|
+
// An explicit --mode browser is a promise to wait, so it overrides the
|
|
1667
|
+
// non-blocking default that a piped stdout would otherwise imply.
|
|
1668
|
+
nonBlocking: wantsNonBlocking && mode !== 'browser',
|
|
1669
|
+
// Only an explicit request starts on the code flow. Every other mode earns
|
|
1670
|
+
// its way there by failing to bind a loopback port -- except on a host whose
|
|
1671
|
+
// loopback the user's browser cannot reach, where binding succeeds and the
|
|
1672
|
+
// callback is unreachable anyway.
|
|
1673
|
+
usePasteCode: mode === 'code' || (mode === 'auto' && !loopbackReachable),
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
|
|
805
1677
|
function redeemCommand(profileName, channel) {
|
|
806
1678
|
return [
|
|
807
1679
|
cliCommandForChannel(channel),
|
|
@@ -810,6 +1682,22 @@ function redeemCommand(profileName, channel) {
|
|
|
810
1682
|
].join(' ');
|
|
811
1683
|
}
|
|
812
1684
|
|
|
1685
|
+
/**
|
|
1686
|
+
* What to run once the browser has finished, when there is no code to redeem.
|
|
1687
|
+
*
|
|
1688
|
+
* The detached listener writes the credential itself, so the only thing left is
|
|
1689
|
+
* to observe that it landed. `start` is idempotent and reports the account, so
|
|
1690
|
+
* it doubles as the confirmation step.
|
|
1691
|
+
*/
|
|
1692
|
+
function confirmCommand(profileName, channel, apiBase) {
|
|
1693
|
+
return [
|
|
1694
|
+
cliCommandForChannel(channel),
|
|
1695
|
+
`--profile ${quoteShellArgument(profileName || 'default')}`,
|
|
1696
|
+
`--api-base ${quoteShellArgument(apiBase)}`,
|
|
1697
|
+
'start',
|
|
1698
|
+
].join(' ');
|
|
1699
|
+
}
|
|
1700
|
+
|
|
813
1701
|
function authorizationChannel(metadata, runtime, pending = null) {
|
|
814
1702
|
return metadata.channel
|
|
815
1703
|
|| pending?.channel
|
|
@@ -882,43 +1770,174 @@ async function redeemAuthorizationCode(runtime, code, fetchImpl) {
|
|
|
882
1770
|
if (!code) {
|
|
883
1771
|
throw oauthError('oauth_code_missing', 'No authorization code was provided.');
|
|
884
1772
|
}
|
|
885
|
-
const
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
1773
|
+
const globalLock = await acquireListenerGlobalLock();
|
|
1774
|
+
let lockDir = null;
|
|
1775
|
+
let metadata = null;
|
|
1776
|
+
let tokenResponse = null;
|
|
1777
|
+
let tokenPersisted = false;
|
|
1778
|
+
try {
|
|
1779
|
+
lockDir = await acquireListenerStartLock(runtime);
|
|
1780
|
+
const pending = readPendingAuthorization(runtime);
|
|
1781
|
+
if (!pending) {
|
|
1782
|
+
throw oauthError(
|
|
1783
|
+
'oauth_pending_login_missing',
|
|
1784
|
+
'No pending authorization for this profile. Run notis login again to start one.',
|
|
1785
|
+
);
|
|
1786
|
+
}
|
|
1787
|
+
// The grant belongs to the environment that issued it. Redeeming without
|
|
1788
|
+
// repeating --api-base must not persist a token under a different backend.
|
|
1789
|
+
if (pending.api_base) {
|
|
1790
|
+
runtime.apiBase = pending.api_base;
|
|
1791
|
+
}
|
|
1792
|
+
metadata = {
|
|
1793
|
+
apiBase: pending.api_base,
|
|
1794
|
+
issuer: pending.issuer,
|
|
1795
|
+
resource: pending.resource,
|
|
1796
|
+
clientId: pending.client_id,
|
|
1797
|
+
tokenEndpoint: pending.token_endpoint,
|
|
1798
|
+
revocationEndpoint: pending.revocation_endpoint
|
|
1799
|
+
|| `${String(pending.issuer || '').replace(/\/+$/, '')}/oauth/revoke`,
|
|
1800
|
+
channel: pending.channel,
|
|
1801
|
+
};
|
|
1802
|
+
if (!metadata.issuer || !metadata.resource || !metadata.clientId || !metadata.tokenEndpoint) {
|
|
1803
|
+
throw oauthError(
|
|
1804
|
+
'oauth_pending_login_invalid',
|
|
1805
|
+
'The pending authorization is incomplete. Run notis login again.',
|
|
1806
|
+
);
|
|
1807
|
+
}
|
|
1808
|
+
// Hold publication ownership through exchange and persistence. Otherwise a
|
|
1809
|
+
// new browser start can retire this verifier while its request is in flight
|
|
1810
|
+
// and the stale result can overwrite the newer authorization afterward.
|
|
1811
|
+
tokenResponse = await exchangeCode(
|
|
1812
|
+
metadata,
|
|
1813
|
+
{ code, redirectUri: pending.redirect_uri, verifier: pending.verifier },
|
|
1814
|
+
fetchImpl,
|
|
890
1815
|
);
|
|
1816
|
+
stopPendingListener(runtime);
|
|
1817
|
+
const profile = persistOAuthTokenResponse(runtime, metadata, tokenResponse);
|
|
1818
|
+
tokenPersisted = true;
|
|
1819
|
+
clearPendingAuthorization(runtime, pending.pending_file);
|
|
1820
|
+
updateRuntimeFromOAuthProfile(runtime, profile);
|
|
1821
|
+
return { profile, metadata };
|
|
1822
|
+
} catch (error) {
|
|
1823
|
+
if (tokenResponse && !tokenPersisted) {
|
|
1824
|
+
await revokeCancelledToken(metadata, tokenResponse, fetchImpl);
|
|
1825
|
+
}
|
|
1826
|
+
throw error;
|
|
1827
|
+
} finally {
|
|
1828
|
+
releaseListenerStartLock(lockDir);
|
|
1829
|
+
releaseListenerGlobalLock(globalLock);
|
|
891
1830
|
}
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
async function exchangeAndPersistForegroundAuthorization(
|
|
1834
|
+
runtime,
|
|
1835
|
+
metadata,
|
|
1836
|
+
authorization,
|
|
1837
|
+
fetchImpl,
|
|
1838
|
+
) {
|
|
1839
|
+
const globalLock = await acquireListenerGlobalLock();
|
|
1840
|
+
let lockDir = null;
|
|
1841
|
+
let tokenResponse = null;
|
|
1842
|
+
let tokenPersisted = false;
|
|
1843
|
+
try {
|
|
1844
|
+
lockDir = await acquireListenerStartLock(runtime);
|
|
1845
|
+
const pending = readPendingAuthorization(runtime);
|
|
1846
|
+
if (!pendingAuthorizationOwnedBy(pending, authorization)) {
|
|
1847
|
+
throw oauthError(
|
|
1848
|
+
'oauth_listener_cancelled',
|
|
1849
|
+
'This browser authorization is no longer active.',
|
|
1850
|
+
);
|
|
1851
|
+
}
|
|
1852
|
+
// Linearize exchange and persistence with both detached publication and
|
|
1853
|
+
// logout. If logout won first, it removed the owner-qualified pending
|
|
1854
|
+
// record above and no grant is minted. If it starts later, it waits and
|
|
1855
|
+
// clears the credential after this operation completes.
|
|
1856
|
+
stopPendingListener(runtime);
|
|
1857
|
+
tokenResponse = await exchangeCode(
|
|
1858
|
+
metadata,
|
|
1859
|
+
{
|
|
1860
|
+
code: authorization.code,
|
|
1861
|
+
redirectUri: authorization.redirect_uri,
|
|
1862
|
+
verifier: authorization.verifier,
|
|
1863
|
+
},
|
|
1864
|
+
fetchImpl,
|
|
908
1865
|
);
|
|
1866
|
+
const profile = persistOAuthTokenResponse(runtime, metadata, tokenResponse);
|
|
1867
|
+
tokenPersisted = true;
|
|
1868
|
+
clearPendingAuthorizationIfOwner(runtime, authorization);
|
|
1869
|
+
return profile;
|
|
1870
|
+
} catch (error) {
|
|
1871
|
+
if (tokenResponse && !tokenPersisted) {
|
|
1872
|
+
await revokeCancelledToken(metadata, tokenResponse, fetchImpl);
|
|
1873
|
+
}
|
|
1874
|
+
throw error;
|
|
1875
|
+
} finally {
|
|
1876
|
+
releaseListenerStartLock(lockDir);
|
|
1877
|
+
releaseListenerGlobalLock(globalLock);
|
|
909
1878
|
}
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
/**
|
|
1882
|
+
* The parked authorization this profile is already waiting on, if this run is
|
|
1883
|
+
* asking for the same thing.
|
|
1884
|
+
*
|
|
1885
|
+
* Minting a fresh verifier instead would silently invalidate the URL the user
|
|
1886
|
+
* was already handed: the code it returns can only be redeemed against the
|
|
1887
|
+
* verifier that was parked with it. Re-running a login is routine -- an agent
|
|
1888
|
+
* does it to check progress -- so the answer has to stay the same URL.
|
|
1889
|
+
*/
|
|
1890
|
+
function reusablePendingAuthorization(runtime, metadata, scopes, requestedTimeoutMs = null) {
|
|
1891
|
+
const pending = readPendingAuthorization(runtime);
|
|
1892
|
+
const pendingScopes = Array.isArray(pending?.scopes) && pending.scopes.length > 0
|
|
1893
|
+
? pending.scopes
|
|
1894
|
+
: DEFAULT_CLI_OAUTH_SCOPES;
|
|
1895
|
+
const sameAuthorization = Boolean(
|
|
1896
|
+
pending
|
|
1897
|
+
&& pending.state
|
|
1898
|
+
&& pending.api_base === runtime.apiBase
|
|
1899
|
+
&& pending.issuer === metadata.issuer
|
|
1900
|
+
&& pending.resource === metadata.resource
|
|
1901
|
+
&& pending.client_id === metadata.clientId
|
|
1902
|
+
&& pending.token_endpoint === metadata.tokenEndpoint
|
|
1903
|
+
&& pending.redirect_uri === metadata.copyPasteRedirectUri
|
|
1904
|
+
&& (
|
|
1905
|
+
requestedTimeoutMs === null
|
|
1906
|
+
|| Number(pending.authorization_timeout_ms) === requestedTimeoutMs
|
|
1907
|
+
)
|
|
1908
|
+
&& JSON.stringify(pendingScopes) === JSON.stringify(scopes),
|
|
914
1909
|
);
|
|
915
|
-
|
|
916
|
-
const
|
|
917
|
-
|
|
918
|
-
|
|
1910
|
+
if (!sameAuthorization) return null;
|
|
1911
|
+
const challenge = createHash('sha256')
|
|
1912
|
+
.update(pending.verifier, 'ascii')
|
|
1913
|
+
.digest('base64url');
|
|
1914
|
+
return {
|
|
1915
|
+
agentAuthorization: {
|
|
1916
|
+
authorize_url: buildAuthorizeUrl(metadata, {
|
|
1917
|
+
redirectUri: pending.redirect_uri,
|
|
1918
|
+
challenge,
|
|
1919
|
+
state: pending.state,
|
|
1920
|
+
scopes: pendingScopes,
|
|
1921
|
+
}),
|
|
1922
|
+
expires_in: Math.max(0, Number(pending.expires_at) - Math.floor(Date.now() / 1000)),
|
|
1923
|
+
hand_off: 'code',
|
|
1924
|
+
redeem_command: redeemCommand(
|
|
1925
|
+
runtime.profileName,
|
|
1926
|
+
authorizationChannel(metadata, runtime, pending),
|
|
1927
|
+
),
|
|
1928
|
+
},
|
|
1929
|
+
};
|
|
919
1930
|
}
|
|
920
1931
|
|
|
921
|
-
export async function loginWithOAuth(
|
|
1932
|
+
export async function loginWithOAuth(
|
|
1933
|
+
runtime,
|
|
1934
|
+
options = {},
|
|
1935
|
+
output,
|
|
1936
|
+
fetchImpl = fetch,
|
|
1937
|
+
createReceiver = createLoopbackReceiver,
|
|
1938
|
+
spawnListener = startDetachedLoopbackListener,
|
|
1939
|
+
readCode = readPastedCode,
|
|
1940
|
+
) {
|
|
922
1941
|
// A worktree profile is authenticated by the running `./dev.sh`, not by a
|
|
923
1942
|
// browser grant. Authorizing over it would replace a scoped test identity
|
|
924
1943
|
// with a real account and quietly point local testing at the wrong user.
|
|
@@ -937,73 +1956,166 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
|
|
|
937
1956
|
],
|
|
938
1957
|
);
|
|
939
1958
|
}
|
|
1959
|
+
// Even redemption is a mutating invocation, so malformed local options must
|
|
1960
|
+
// fail before reading pending state or contacting the token endpoint.
|
|
1961
|
+
requestedLoginMode(options);
|
|
1962
|
+
const requestedTimeoutMs = requestedAuthorizationTimeoutMs(options);
|
|
940
1963
|
if (options.code) {
|
|
941
1964
|
return redeemAuthorizationCode(runtime, String(options.code).trim(), fetchImpl);
|
|
942
1965
|
}
|
|
943
1966
|
|
|
1967
|
+
// Reject a malformed local invocation before making discovery requests.
|
|
1968
|
+
const loginMode = resolveLoginMode(runtime, options);
|
|
944
1969
|
const metadata = await discoverCliOAuth(runtime.apiBase, fetchImpl);
|
|
945
1970
|
const scopes = options.scope?.length
|
|
946
1971
|
? [...new Set(options.scope)]
|
|
947
1972
|
: DEFAULT_CLI_OAUTH_SCOPES;
|
|
948
|
-
const timeoutMs =
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1973
|
+
const timeoutMs = requestedTimeoutMs ?? DEFAULT_LOGIN_TIMEOUT_MS;
|
|
1974
|
+
// A detached listener is not a terminal waiting on a prompt: nobody is held
|
|
1975
|
+
// up by it, and the user still has to sign up and verify an email, so its
|
|
1976
|
+
// default is the parked-authorization window rather than the blocking one.
|
|
1977
|
+
// An explicit --timeout-seconds still wins; the flag documents how long the
|
|
1978
|
+
// authorization stays open, and silently ignoring it here made it a lie.
|
|
1979
|
+
const parkedAuthorizationTimeoutMs = requestedTimeoutMs ?? PENDING_LOGIN_TTL_SECONDS * 1000;
|
|
1980
|
+
const {
|
|
1981
|
+
automaticMode,
|
|
1982
|
+
wantsNonBlocking,
|
|
1983
|
+
nonBlocking,
|
|
1984
|
+
usePasteCode,
|
|
1985
|
+
} = loginMode;
|
|
1986
|
+
let nonBlockingAgent = nonBlocking;
|
|
1987
|
+
let listenerGlobalLock = null;
|
|
1988
|
+
let listenerStartLock = null;
|
|
957
1989
|
let receiver;
|
|
1990
|
+
let detachedListener = null;
|
|
1991
|
+
let detachedListenerPublished = false;
|
|
958
1992
|
let redirectUri;
|
|
1993
|
+
let foregroundAuthorization = null;
|
|
959
1994
|
|
|
960
|
-
if (
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1995
|
+
if (automaticMode || nonBlockingAgent || usePasteCode || options.reusePersistedCredential) {
|
|
1996
|
+
listenerGlobalLock = await acquireListenerGlobalLock();
|
|
1997
|
+
try {
|
|
1998
|
+
listenerStartLock = await acquireListenerStartLock(runtime);
|
|
1999
|
+
} catch (error) {
|
|
2000
|
+
releaseListenerGlobalLock(listenerGlobalLock);
|
|
2001
|
+
listenerGlobalLock = null;
|
|
2002
|
+
throw error;
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
try {
|
|
2007
|
+
if (options.reusePersistedCredential) {
|
|
2008
|
+
// `start` doubles as the confirmation command for a detached login. The
|
|
2009
|
+
// child can persist its token while this invocation is doing discovery;
|
|
2010
|
+
// re-read under the publication lock so an idempotent confirmation cannot
|
|
2011
|
+
// mint a second grant from a stale runtime snapshot.
|
|
2012
|
+
const storedProfile = getProfile(loadConfig(), runtime.profileName);
|
|
2013
|
+
if (
|
|
2014
|
+
storedProfile.oauth_access_token
|
|
2015
|
+
&& !credentialIsExpired({ credentialKind: 'oauth' }, storedProfile)
|
|
2016
|
+
) {
|
|
2017
|
+
assertOAuthApiTarget(runtime, storedProfile);
|
|
2018
|
+
updateRuntimeFromOAuthProfile(runtime, storedProfile);
|
|
2019
|
+
return { profile: storedProfile, metadata, reusedPersistedCredential: true };
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
// A listener spawned by an earlier run is still holding the browser hand-off,
|
|
2023
|
+
// and its URL is the only one whose verifier that process knows.
|
|
2024
|
+
if (automaticMode && !usePasteCode) {
|
|
2025
|
+
// Auto mode may have degraded to the copy-code hand-off on an earlier run.
|
|
2026
|
+
// That parked verifier owns the URL the user already received, so keep the
|
|
2027
|
+
// hand-off sticky instead of publishing a competing loopback authorization.
|
|
2028
|
+
const parked = reusablePendingAuthorization(runtime, metadata, scopes, requestedTimeoutMs);
|
|
2029
|
+
if (parked) return parked;
|
|
2030
|
+
// A non-reusable sidecar belongs to a different/expired authorization. It
|
|
2031
|
+
// must not survive beside the listener this run is about to publish.
|
|
2032
|
+
clearPendingAuthorizations(runtime);
|
|
2033
|
+
const live = readLiveListener(runtime, { sameApiBase: false });
|
|
2034
|
+
// Reuse is only safe when the live listener is authorizing the *same*
|
|
2035
|
+
// thing. The copy-paste path below already compares the full authorization;
|
|
2036
|
+
// matching only the profile here would hand back a URL carrying the scopes
|
|
2037
|
+
// of the earlier run, silently ignoring this one's --scope.
|
|
2038
|
+
const sameListenerAuthorization = Boolean(
|
|
2039
|
+
live
|
|
2040
|
+
&& live.api_base === runtime.apiBase
|
|
2041
|
+
&& live.issuer === metadata.issuer
|
|
2042
|
+
&& live.resource === metadata.resource
|
|
2043
|
+
&& live.client_id === metadata.clientId
|
|
2044
|
+
&& live.token_endpoint === metadata.tokenEndpoint
|
|
2045
|
+
&& (
|
|
2046
|
+
requestedTimeoutMs === null
|
|
2047
|
+
|| Number(live.authorization_timeout_ms) === requestedTimeoutMs
|
|
2048
|
+
)
|
|
2049
|
+
&& JSON.stringify(live.scopes || []) === JSON.stringify(scopes),
|
|
975
2050
|
);
|
|
976
|
-
if (
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
2051
|
+
if (live && !sameListenerAuthorization) {
|
|
2052
|
+
// A different authorization is being requested, so the old listener is
|
|
2053
|
+
// now unreachable work holding a port. Stop it before spawning another.
|
|
2054
|
+
stopPendingListener(runtime);
|
|
2055
|
+
}
|
|
2056
|
+
if (live && sameListenerAuthorization) {
|
|
980
2057
|
return {
|
|
981
2058
|
agentAuthorization: {
|
|
982
|
-
authorize_url:
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
scopes: pendingScopes,
|
|
987
|
-
}),
|
|
988
|
-
expires_in: Math.max(0, Number(pending.expires_at) - Math.floor(Date.now() / 1000)),
|
|
989
|
-
redeem_command: redeemCommand(
|
|
2059
|
+
authorize_url: live.authorize_url,
|
|
2060
|
+
expires_in: Math.max(0, Number(live.expires_at) - Math.floor(Date.now() / 1000)),
|
|
2061
|
+
hand_off: 'browser_callback',
|
|
2062
|
+
confirm_command: confirmCommand(
|
|
990
2063
|
runtime.profileName,
|
|
991
|
-
authorizationChannel(metadata, runtime
|
|
2064
|
+
authorizationChannel(metadata, runtime),
|
|
2065
|
+
live.api_base,
|
|
992
2066
|
),
|
|
993
2067
|
},
|
|
994
2068
|
};
|
|
995
2069
|
}
|
|
996
2070
|
}
|
|
997
2071
|
|
|
2072
|
+
if (!nonBlockingAgent && !usePasteCode) {
|
|
2073
|
+
// A foreground browser flow never holds publication locks while a person
|
|
2074
|
+
// completes authorization. This also covers explicit browser mode and a
|
|
2075
|
+
// start command checking for an already-persisted credential.
|
|
2076
|
+
releaseListenerStartLock(listenerStartLock);
|
|
2077
|
+
listenerStartLock = null;
|
|
2078
|
+
releaseListenerGlobalLock(listenerGlobalLock);
|
|
2079
|
+
listenerGlobalLock = null;
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
if (usePasteCode) {
|
|
2083
|
+
const reused = reusablePendingAuthorization(runtime, metadata, scopes, requestedTimeoutMs);
|
|
2084
|
+
if (reused) return reused;
|
|
2085
|
+
// Code and loopback hand-offs are mutually exclusive for one profile. This
|
|
2086
|
+
// runs under the same publication lock as detached starts so a code request
|
|
2087
|
+
// cannot leave a second valid authorization beside a listener that is
|
|
2088
|
+
// still being published.
|
|
2089
|
+
stopPendingListener(runtime);
|
|
2090
|
+
}
|
|
2091
|
+
|
|
998
2092
|
const { verifier, challenge } = createPkce();
|
|
999
2093
|
const state = base64url(randomBytes(32));
|
|
1000
2094
|
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
2095
|
+
// May flip to true below: the browser hand-off is a preference, not a
|
|
2096
|
+
// guarantee, and the redirect URI is signed into the authorize URL before the
|
|
2097
|
+
// user ever sees it. Deciding here is the last moment a fallback is free.
|
|
2098
|
+
let pasteCode = usePasteCode;
|
|
2099
|
+
|
|
2100
|
+
const degradeToCode = (error) => {
|
|
2101
|
+
// A locked-down machine that cannot bind 127.0.0.1 used to dead-end here
|
|
2102
|
+
// with no way forward, even though the Portal hand-off would have worked.
|
|
2103
|
+
// Only a missing copy-paste callback is genuinely unrecoverable.
|
|
2104
|
+
if (!metadata.copyPasteRedirectUri) throw error;
|
|
2105
|
+
receiver = undefined;
|
|
2106
|
+
pasteCode = true;
|
|
2107
|
+
// --mode browser promised to wait for a browser callback, not for someone
|
|
2108
|
+
// to type into a pipe. Once no listener exists, returning the URL beats
|
|
2109
|
+
// prompting on a stdout nobody is reading. A caller that declared itself
|
|
2110
|
+
// non-interactive is in the same position: `readPastedCode` would block on
|
|
2111
|
+
// a stdin prompt it has already said it cannot answer.
|
|
2112
|
+
nonBlockingAgent = wantsNonBlocking || Boolean(runtime.nonInteractive);
|
|
2113
|
+
output?.note?.(
|
|
2114
|
+
'The local callback listener could not start, so this login switched to the copy-paste code flow.',
|
|
2115
|
+
);
|
|
2116
|
+
};
|
|
2117
|
+
|
|
2118
|
+
if (!pasteCode) {
|
|
1007
2119
|
let portalOrigin = 'https://app.notis.ai';
|
|
1008
2120
|
try {
|
|
1009
2121
|
portalOrigin = new URL(metadata.copyPasteRedirectUri).origin;
|
|
@@ -1011,8 +2123,62 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
|
|
|
1011
2123
|
// The OAuth server owns this metadata. Keep the public Portal fallback if
|
|
1012
2124
|
// a development server omits or returns an invalid copy-paste URL.
|
|
1013
2125
|
}
|
|
1014
|
-
|
|
1015
|
-
|
|
2126
|
+
if (nonBlockingAgent) {
|
|
2127
|
+
// This command has to return before the user has even opened the URL, so
|
|
2128
|
+
// the listener has to belong to something else.
|
|
2129
|
+
detachedListener = await spawnListener(runtime, {
|
|
2130
|
+
metadata,
|
|
2131
|
+
verifier,
|
|
2132
|
+
state,
|
|
2133
|
+
scopes,
|
|
2134
|
+
timeoutMs: parkedAuthorizationTimeoutMs,
|
|
2135
|
+
portalOrigin,
|
|
2136
|
+
});
|
|
2137
|
+
if (detachedListener) {
|
|
2138
|
+
redirectUri = detachedListener.redirectUri;
|
|
2139
|
+
} else {
|
|
2140
|
+
degradeToCode(oauthError(
|
|
2141
|
+
'oauth_loopback_failed',
|
|
2142
|
+
'Could not start a background OAuth callback listener.',
|
|
2143
|
+
));
|
|
2144
|
+
}
|
|
2145
|
+
} else {
|
|
2146
|
+
try {
|
|
2147
|
+
receiver = await createReceiver({ state, timeoutMs, portalOrigin });
|
|
2148
|
+
redirectUri = receiver.redirectUri;
|
|
2149
|
+
} catch (error) {
|
|
2150
|
+
degradeToCode(error);
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
if (pasteCode) {
|
|
2156
|
+
// A loopback bind may have degraded after the initial mode decision. Code
|
|
2157
|
+
// publication still has to join the same serialization protocol as every
|
|
2158
|
+
// detached start before it writes a verifier sidecar.
|
|
2159
|
+
if (!listenerStartLock) {
|
|
2160
|
+
listenerGlobalLock = await acquireListenerGlobalLock();
|
|
2161
|
+
try {
|
|
2162
|
+
listenerStartLock = await acquireListenerStartLock(runtime);
|
|
2163
|
+
} catch (error) {
|
|
2164
|
+
releaseListenerGlobalLock(listenerGlobalLock);
|
|
2165
|
+
listenerGlobalLock = null;
|
|
2166
|
+
throw error;
|
|
2167
|
+
}
|
|
2168
|
+
const live = readLiveListener(runtime, { sameApiBase: false });
|
|
2169
|
+
if (live) stopPendingListener(runtime);
|
|
2170
|
+
}
|
|
2171
|
+
if (!usePasteCode) {
|
|
2172
|
+
// This run degraded into the code flow rather than starting there, so it
|
|
2173
|
+
// has not yet checked for a parked authorization. Overwriting one would
|
|
2174
|
+
// make the URL an earlier run already handed the user unredeemable.
|
|
2175
|
+
const reused = reusablePendingAuthorization(runtime, metadata, scopes, requestedTimeoutMs);
|
|
2176
|
+
if (reused) return reused;
|
|
2177
|
+
}
|
|
2178
|
+
redirectUri = metadata.copyPasteRedirectUri;
|
|
2179
|
+
if (!redirectUri) {
|
|
2180
|
+
throw oauthError('oauth_metadata_invalid', 'Notis did not advertise a copy-paste callback.');
|
|
2181
|
+
}
|
|
1016
2182
|
}
|
|
1017
2183
|
|
|
1018
2184
|
const authorizeUrl = buildAuthorizeUrl(metadata, {
|
|
@@ -1037,19 +2203,81 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
|
|
|
1037
2203
|
resource: metadata.resource,
|
|
1038
2204
|
client_id: metadata.clientId,
|
|
1039
2205
|
token_endpoint: metadata.tokenEndpoint,
|
|
2206
|
+
revocation_endpoint: metadata.revocationEndpoint,
|
|
1040
2207
|
authorization_endpoint: metadata.authorizationEndpoint,
|
|
1041
2208
|
channel: authorizationChannel(metadata, runtime),
|
|
1042
2209
|
scopes,
|
|
1043
|
-
|
|
2210
|
+
authorization_timeout_ms: parkedAuthorizationTimeoutMs,
|
|
2211
|
+
expires_at: Math.floor(Date.now() / 1000) + Math.ceil(parkedAuthorizationTimeoutMs / 1000),
|
|
1044
2212
|
});
|
|
2213
|
+
} else if (!nonBlockingAgent) {
|
|
2214
|
+
foregroundAuthorization = {
|
|
2215
|
+
profile: runtime.profileName,
|
|
2216
|
+
api_base: runtime.apiBase,
|
|
2217
|
+
verifier,
|
|
2218
|
+
state,
|
|
2219
|
+
redirect_uri: redirectUri,
|
|
2220
|
+
hand_off: 'browser_callback',
|
|
2221
|
+
issuer: metadata.issuer,
|
|
2222
|
+
resource: metadata.resource,
|
|
2223
|
+
client_id: metadata.clientId,
|
|
2224
|
+
token_endpoint: metadata.tokenEndpoint,
|
|
2225
|
+
revocation_endpoint: metadata.revocationEndpoint,
|
|
2226
|
+
authorization_endpoint: metadata.authorizationEndpoint,
|
|
2227
|
+
channel: authorizationChannel(metadata, runtime),
|
|
2228
|
+
scopes,
|
|
2229
|
+
authorization_timeout_ms: timeoutMs,
|
|
2230
|
+
expires_at: Math.floor(Date.now() / 1000) + Math.ceil(timeoutMs / 1000),
|
|
2231
|
+
};
|
|
2232
|
+
// Logout and competing logins use this owner-qualified sidecar to cancel
|
|
2233
|
+
// a foreground authorization before it can exchange or persist a grant.
|
|
2234
|
+
await publishForegroundAuthorization(runtime, foregroundAuthorization);
|
|
1045
2235
|
}
|
|
1046
2236
|
|
|
1047
2237
|
if (nonBlockingAgent) {
|
|
1048
2238
|
await receiver?.close();
|
|
2239
|
+
if (detachedListener) {
|
|
2240
|
+
const listenerTtlSeconds = Math.round(parkedAuthorizationTimeoutMs / 1000);
|
|
2241
|
+
saveListenerState(runtime, {
|
|
2242
|
+
profile: runtime.profileName,
|
|
2243
|
+
api_base: runtime.apiBase,
|
|
2244
|
+
pid: detachedListener.pid,
|
|
2245
|
+
port: detachedListener.port,
|
|
2246
|
+
authorize_url: authorizeUrl,
|
|
2247
|
+
issuer: metadata.issuer,
|
|
2248
|
+
resource: metadata.resource,
|
|
2249
|
+
client_id: metadata.clientId,
|
|
2250
|
+
token_endpoint: metadata.tokenEndpoint,
|
|
2251
|
+
scopes,
|
|
2252
|
+
authorization_timeout_ms: parkedAuthorizationTimeoutMs,
|
|
2253
|
+
identity_token: detachedListener.identityToken,
|
|
2254
|
+
listener_script: detachedListener.scriptPath,
|
|
2255
|
+
// The record must not outlive the child it points at, or reuse hands
|
|
2256
|
+
// back a URL whose listener has already given up.
|
|
2257
|
+
expires_at: Math.floor(Date.now() / 1000) + listenerTtlSeconds,
|
|
2258
|
+
});
|
|
2259
|
+
detachedListenerPublished = true;
|
|
2260
|
+
return {
|
|
2261
|
+
agentAuthorization: {
|
|
2262
|
+
authorize_url: authorizeUrl,
|
|
2263
|
+
expires_in: listenerTtlSeconds,
|
|
2264
|
+
// Nothing to copy: the browser returns the code to the listener the
|
|
2265
|
+
// child is holding, and the credential is written before the user is
|
|
2266
|
+
// told they are connected.
|
|
2267
|
+
hand_off: 'browser_callback',
|
|
2268
|
+
confirm_command: confirmCommand(
|
|
2269
|
+
runtime.profileName,
|
|
2270
|
+
authorizationChannel(metadata, runtime),
|
|
2271
|
+
runtime.apiBase,
|
|
2272
|
+
),
|
|
2273
|
+
},
|
|
2274
|
+
};
|
|
2275
|
+
}
|
|
1049
2276
|
return {
|
|
1050
2277
|
agentAuthorization: {
|
|
1051
2278
|
authorize_url: authorizeUrl,
|
|
1052
|
-
expires_in:
|
|
2279
|
+
expires_in: Math.round(parkedAuthorizationTimeoutMs / 1000),
|
|
2280
|
+
hand_off: 'code',
|
|
1053
2281
|
redeem_command: redeemCommand(
|
|
1054
2282
|
runtime.profileName,
|
|
1055
2283
|
authorizationChannel(metadata, runtime),
|
|
@@ -1058,21 +2286,35 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
|
|
|
1058
2286
|
};
|
|
1059
2287
|
}
|
|
1060
2288
|
|
|
2289
|
+
if (pasteCode) {
|
|
2290
|
+
// Do not hold publication locks while a human finds and pastes a code. The
|
|
2291
|
+
// parked verifier is now authoritative; redemption reacquires both locks
|
|
2292
|
+
// and validates that it still owns the profile before exchanging.
|
|
2293
|
+
releaseListenerStartLock(listenerStartLock);
|
|
2294
|
+
listenerStartLock = null;
|
|
2295
|
+
releaseListenerGlobalLock(listenerGlobalLock);
|
|
2296
|
+
listenerGlobalLock = null;
|
|
2297
|
+
}
|
|
2298
|
+
|
|
1061
2299
|
// Always surface the URL: opening the browser can fail silently, and the
|
|
1062
2300
|
// wait below is useless without something the user can paste themselves.
|
|
1063
|
-
output?.
|
|
2301
|
+
const announceAuthorization = output?.notice || output?.note;
|
|
2302
|
+
announceAuthorization?.call(output, `Authorize Notis CLI: ${authorizeUrl}`);
|
|
1064
2303
|
if (options.browser !== false && !pasteCode) {
|
|
1065
2304
|
openBrowser(authorizeUrl);
|
|
1066
2305
|
}
|
|
1067
2306
|
|
|
1068
2307
|
try {
|
|
1069
|
-
|
|
1070
|
-
|
|
2308
|
+
if (pasteCode) {
|
|
2309
|
+
return await redeemAuthorizationCode(runtime, await readCode(), fetchImpl);
|
|
2310
|
+
}
|
|
2311
|
+
const code = await receiver.waitForCode();
|
|
2312
|
+
const profile = await exchangeAndPersistForegroundAuthorization(
|
|
2313
|
+
runtime,
|
|
1071
2314
|
metadata,
|
|
1072
|
-
{
|
|
2315
|
+
{ ...foregroundAuthorization, code },
|
|
1073
2316
|
fetchImpl,
|
|
1074
2317
|
);
|
|
1075
|
-
const profile = persistOAuthTokenResponse(runtime, metadata, tokenResponse);
|
|
1076
2318
|
updateRuntimeFromOAuthProfile(runtime, profile);
|
|
1077
2319
|
receiver?.complete();
|
|
1078
2320
|
return { profile, metadata };
|
|
@@ -1081,9 +2323,74 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
|
|
|
1081
2323
|
throw error;
|
|
1082
2324
|
} finally {
|
|
1083
2325
|
await receiver?.close();
|
|
2326
|
+
if (foregroundAuthorization) {
|
|
2327
|
+
const globalLock = await acquireListenerGlobalLock();
|
|
2328
|
+
let lockDir = null;
|
|
2329
|
+
try {
|
|
2330
|
+
lockDir = await acquireListenerStartLock(runtime);
|
|
2331
|
+
clearPendingAuthorizationIfOwner(runtime, foregroundAuthorization);
|
|
2332
|
+
} finally {
|
|
2333
|
+
releaseListenerStartLock(lockDir);
|
|
2334
|
+
releaseListenerGlobalLock(globalLock);
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
} finally {
|
|
2339
|
+
if (detachedListener && !detachedListenerPublished) {
|
|
2340
|
+
try { process.kill(detachedListener.pid); } catch { /* already gone */ }
|
|
2341
|
+
clearListenerState(runtime, { ownerPid: detachedListener.pid });
|
|
2342
|
+
}
|
|
2343
|
+
releaseListenerStartLock(listenerStartLock);
|
|
2344
|
+
releaseListenerGlobalLock(listenerGlobalLock);
|
|
1084
2345
|
}
|
|
1085
2346
|
}
|
|
1086
2347
|
|
|
2348
|
+
function pendingListenerProfiles() {
|
|
2349
|
+
const configFile = resolveConfigFile();
|
|
2350
|
+
const prefix = `${basename(configFile)}.login-listener.`;
|
|
2351
|
+
let entries;
|
|
2352
|
+
try {
|
|
2353
|
+
entries = readdirSync(dirname(configFile));
|
|
2354
|
+
} catch {
|
|
2355
|
+
return [];
|
|
2356
|
+
}
|
|
2357
|
+
const profiles = new Set();
|
|
2358
|
+
for (const entry of entries) {
|
|
2359
|
+
if (!entry.startsWith(prefix) || entry.includes('.payload') || entry.endsWith('.lock')) continue;
|
|
2360
|
+
try {
|
|
2361
|
+
const state = JSON.parse(readFileSync(join(dirname(configFile), entry), 'utf-8'));
|
|
2362
|
+
if (typeof state?.profile === 'string' && state.profile) profiles.add(state.profile);
|
|
2363
|
+
} catch {
|
|
2364
|
+
// Ignore malformed or concurrently removed sidecars.
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
return [...profiles];
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
function pendingAuthorizationProfiles() {
|
|
2371
|
+
const configFile = resolveConfigFile();
|
|
2372
|
+
const configName = basename(configFile);
|
|
2373
|
+
const prefix = `${configName}.pending-login.`;
|
|
2374
|
+
const legacyName = `${configName}.pending-login`;
|
|
2375
|
+
let entries;
|
|
2376
|
+
try {
|
|
2377
|
+
entries = readdirSync(dirname(configFile));
|
|
2378
|
+
} catch {
|
|
2379
|
+
return [];
|
|
2380
|
+
}
|
|
2381
|
+
const profiles = new Set();
|
|
2382
|
+
for (const entry of entries) {
|
|
2383
|
+
if (entry !== legacyName && !entry.startsWith(prefix)) continue;
|
|
2384
|
+
try {
|
|
2385
|
+
const pending = JSON.parse(readFileSync(join(dirname(configFile), entry), 'utf-8'));
|
|
2386
|
+
if (typeof pending?.profile === 'string' && pending.profile) profiles.add(pending.profile);
|
|
2387
|
+
} catch {
|
|
2388
|
+
// Ignore malformed or concurrently removed sidecars.
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
return [...profiles];
|
|
2392
|
+
}
|
|
2393
|
+
|
|
1087
2394
|
function lockIsStale() {
|
|
1088
2395
|
try {
|
|
1089
2396
|
return Date.now() - statSync(OAUTH_LOCK_DIR).mtimeMs > 45_000;
|
|
@@ -1127,8 +2434,17 @@ async function acquireRefreshLock(runtime, waitMs = 60_000) {
|
|
|
1127
2434
|
}
|
|
1128
2435
|
|
|
1129
2436
|
export async function refreshOAuthCredential(runtime, fetchImpl = fetch) {
|
|
2437
|
+
// Refresh rotates the same stored grant that login publishes and logout
|
|
2438
|
+
// removes. Join their global -> profile lock order so a token rotation can
|
|
2439
|
+
// never be mistaken for a successor authorization by a concurrent logout.
|
|
2440
|
+
const globalLock = await acquireListenerGlobalLock();
|
|
2441
|
+
let profileLock = null;
|
|
1130
2442
|
let ownsLock = false;
|
|
2443
|
+
let metadata = null;
|
|
2444
|
+
let rotatedResponse = null;
|
|
2445
|
+
let rotatedPersisted = false;
|
|
1131
2446
|
try {
|
|
2447
|
+
profileLock = await acquireListenerStartLock(runtime);
|
|
1132
2448
|
ownsLock = await acquireRefreshLock(runtime);
|
|
1133
2449
|
if (!ownsLock) return true;
|
|
1134
2450
|
const config = loadConfig();
|
|
@@ -1146,8 +2462,8 @@ export async function refreshOAuthCredential(runtime, fetchImpl = fetch) {
|
|
|
1146
2462
|
return false;
|
|
1147
2463
|
}
|
|
1148
2464
|
|
|
1149
|
-
|
|
1150
|
-
|
|
2465
|
+
metadata = storedOAuthMetadata(runtime, profile);
|
|
2466
|
+
rotatedResponse = await fetchJson(
|
|
1151
2467
|
metadata.tokenEndpoint,
|
|
1152
2468
|
{
|
|
1153
2469
|
method: 'POST',
|
|
@@ -1161,10 +2477,14 @@ export async function refreshOAuthCredential(runtime, fetchImpl = fetch) {
|
|
|
1161
2477
|
},
|
|
1162
2478
|
fetchImpl,
|
|
1163
2479
|
);
|
|
1164
|
-
const updated = persistOAuthTokenResponse(runtime, metadata,
|
|
2480
|
+
const updated = persistOAuthTokenResponse(runtime, metadata, rotatedResponse);
|
|
2481
|
+
rotatedPersisted = true;
|
|
1165
2482
|
updateRuntimeFromOAuthProfile(runtime, updated);
|
|
1166
2483
|
return true;
|
|
1167
2484
|
} catch (error) {
|
|
2485
|
+
if (rotatedResponse && !rotatedPersisted) {
|
|
2486
|
+
await revokeCancelledToken(metadata, rotatedResponse, fetchImpl);
|
|
2487
|
+
}
|
|
1168
2488
|
if (error instanceof CliError) {
|
|
1169
2489
|
throw new CliError({
|
|
1170
2490
|
code: error.code,
|
|
@@ -1186,6 +2506,8 @@ export async function refreshOAuthCredential(runtime, fetchImpl = fetch) {
|
|
|
1186
2506
|
// A process exit or external cleanup may already have removed the lock.
|
|
1187
2507
|
}
|
|
1188
2508
|
}
|
|
2509
|
+
releaseListenerStartLock(profileLock);
|
|
2510
|
+
releaseListenerGlobalLock(globalLock);
|
|
1189
2511
|
}
|
|
1190
2512
|
}
|
|
1191
2513
|
|
|
@@ -1203,55 +2525,98 @@ export async function logoutOAuth(runtime, { allProfiles = false } = {}, fetchIm
|
|
|
1203
2525
|
],
|
|
1204
2526
|
);
|
|
1205
2527
|
}
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
const
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
2528
|
+
// A single-profile logout mutates the same state as login publication and
|
|
2529
|
+
// refresh, so it needs the global lock just as much as --all-profiles does.
|
|
2530
|
+
const globalLock = await acquireListenerGlobalLock();
|
|
2531
|
+
try {
|
|
2532
|
+
const config = loadConfig();
|
|
2533
|
+
const profileNames = allProfiles
|
|
2534
|
+
? [...new Set([
|
|
2535
|
+
...Object.keys(config.profiles),
|
|
2536
|
+
...pendingListenerProfiles(),
|
|
2537
|
+
...pendingAuthorizationProfiles(),
|
|
2538
|
+
])]
|
|
2539
|
+
: [runtime.profileName];
|
|
2540
|
+
const clearedProfiles = [];
|
|
2541
|
+
for (const profileName of profileNames) {
|
|
2542
|
+
// An unfinished login is still holding a listener that would write this
|
|
2543
|
+
// profile back in the moment the old URL is opened. Signing out has to end
|
|
2544
|
+
// the authorization in flight, not just the one already stored.
|
|
2545
|
+
const profileRuntime = { ...runtime, profileName };
|
|
2546
|
+
const profileLock = await acquireListenerStartLock(profileRuntime);
|
|
1217
2547
|
try {
|
|
1218
|
-
const
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
}),
|
|
1228
|
-
},
|
|
1229
|
-
fetchImpl,
|
|
2548
|
+
const latestBeforeClear = loadConfig();
|
|
2549
|
+
const storedProfile = latestBeforeClear.profiles[profileName] || {};
|
|
2550
|
+
const hadStoredGrant = Boolean(
|
|
2551
|
+
storedProfile.oauth_access_token
|
|
2552
|
+
|| storedProfile.oauth_refresh_token
|
|
2553
|
+
|| storedProfile.oauth_client_id
|
|
2554
|
+
|| storedProfile.oauth_issuer
|
|
2555
|
+
|| storedProfile.oauth_resource
|
|
2556
|
+
|| storedProfile.oauth_user_id
|
|
1230
2557
|
);
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
2558
|
+
const hadPendingAuthorization = Boolean(readPendingAuthorization(profileRuntime));
|
|
2559
|
+
const hadListener = Boolean(readListenerState(profileRuntime));
|
|
2560
|
+
if (hadStoredGrant || hadPendingAuthorization || hadListener) {
|
|
2561
|
+
clearedProfiles.push(profileName);
|
|
2562
|
+
}
|
|
2563
|
+
clearPendingAuthorizations(profileRuntime);
|
|
2564
|
+
stopPendingListener(profileRuntime);
|
|
2565
|
+
// Read only after both locks are held. A publication or refresh that
|
|
2566
|
+
// won first is part of this logout; one that starts later observes the
|
|
2567
|
+
// cleared profile instead of resurrecting it afterward.
|
|
2568
|
+
const profile = loadConfig().profiles[profileName] || {};
|
|
2569
|
+
const revocationToken = profile.oauth_refresh_token || profile.oauth_access_token;
|
|
2570
|
+
if (
|
|
2571
|
+
revocationToken
|
|
2572
|
+
&& profile.oauth_client_id
|
|
2573
|
+
&& profile.oauth_issuer
|
|
2574
|
+
) {
|
|
2575
|
+
try {
|
|
2576
|
+
const metadata = storedOAuthMetadata(runtime, profile);
|
|
2577
|
+
await fetchJson(
|
|
2578
|
+
metadata.revocationEndpoint,
|
|
2579
|
+
{
|
|
2580
|
+
method: 'POST',
|
|
2581
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
2582
|
+
body: new URLSearchParams({
|
|
2583
|
+
token: revocationToken,
|
|
2584
|
+
client_id: profile.oauth_client_id,
|
|
2585
|
+
}),
|
|
2586
|
+
},
|
|
2587
|
+
fetchImpl,
|
|
2588
|
+
);
|
|
2589
|
+
} catch {
|
|
2590
|
+
// Local credential removal still succeeds when the remote grant is
|
|
2591
|
+
// already gone or the network is unavailable.
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
updateConfig((latest) => {
|
|
2595
|
+
const current = latest.profiles[profileName];
|
|
2596
|
+
// A named pending login has no stored profile yet. Cancelling it must
|
|
2597
|
+
// not create an empty profile as a side effect of logout.
|
|
2598
|
+
if (!current) return latest;
|
|
2599
|
+
latest.profiles[profileName] = {
|
|
2600
|
+
...current,
|
|
2601
|
+
oauth_access_token: undefined,
|
|
2602
|
+
oauth_refresh_token: undefined,
|
|
2603
|
+
oauth_access_expires_at: undefined,
|
|
2604
|
+
oauth_refresh_expires_at: undefined,
|
|
2605
|
+
oauth_client_id: undefined,
|
|
2606
|
+
oauth_issuer: undefined,
|
|
2607
|
+
oauth_api_base: undefined,
|
|
2608
|
+
oauth_resource: undefined,
|
|
2609
|
+
oauth_scopes: undefined,
|
|
2610
|
+
oauth_user_id: undefined,
|
|
2611
|
+
};
|
|
2612
|
+
return latest;
|
|
2613
|
+
});
|
|
2614
|
+
} finally {
|
|
2615
|
+
releaseListenerStartLock(profileLock);
|
|
1234
2616
|
}
|
|
1235
2617
|
}
|
|
2618
|
+
return { profiles: clearedProfiles };
|
|
2619
|
+
} finally {
|
|
2620
|
+
releaseListenerGlobalLock(globalLock);
|
|
1236
2621
|
}
|
|
1237
|
-
updateConfig((latest) => {
|
|
1238
|
-
for (const profileName of profileNames) {
|
|
1239
|
-
const profile = latest.profiles[profileName] || {};
|
|
1240
|
-
latest.profiles[profileName] = {
|
|
1241
|
-
...profile,
|
|
1242
|
-
oauth_access_token: undefined,
|
|
1243
|
-
oauth_refresh_token: undefined,
|
|
1244
|
-
oauth_access_expires_at: undefined,
|
|
1245
|
-
oauth_refresh_expires_at: undefined,
|
|
1246
|
-
oauth_client_id: undefined,
|
|
1247
|
-
oauth_issuer: undefined,
|
|
1248
|
-
oauth_api_base: undefined,
|
|
1249
|
-
oauth_resource: undefined,
|
|
1250
|
-
oauth_scopes: undefined,
|
|
1251
|
-
oauth_user_id: undefined,
|
|
1252
|
-
};
|
|
1253
|
-
}
|
|
1254
|
-
return latest;
|
|
1255
|
-
});
|
|
1256
|
-
return { profiles: profileNames };
|
|
1257
2622
|
}
|