@jmoyers/harness 0.1.10 → 0.1.20
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 +31 -35
- package/package.json +31 -11
- package/packages/harness-ai/src/anthropic-protocol.ts +68 -68
- package/packages/harness-ai/src/stream-text.ts +13 -91
- package/packages/harness-ui/src/frame-primitives.ts +158 -0
- package/packages/harness-ui/src/index.ts +18 -0
- package/packages/harness-ui/src/interaction/conversation-input-forwarder.ts +221 -0
- package/packages/harness-ui/src/interaction/conversation-selection-input.ts +213 -0
- package/packages/harness-ui/src/interaction/global-shortcut-input.ts +172 -0
- package/{src/ui → packages/harness-ui/src/interaction}/input-preflight.ts +10 -12
- package/{src/ui → packages/harness-ui/src/interaction}/input-token-router.ts +120 -69
- package/packages/harness-ui/src/interaction/input.ts +420 -0
- package/packages/harness-ui/src/interaction/left-nav-input.ts +166 -0
- package/{src/ui → packages/harness-ui/src/interaction}/main-pane-pointer-input.ts +91 -23
- package/{src/ui → packages/harness-ui/src/interaction}/pointer-routing-input.ts +112 -48
- package/packages/harness-ui/src/interaction/rail-pointer-input.ts +62 -0
- package/packages/harness-ui/src/interaction/repository-fold-input.ts +118 -0
- package/packages/harness-ui/src/kit.ts +476 -0
- package/packages/harness-ui/src/layout.ts +238 -0
- package/{src/ui/modals/manager.ts → packages/harness-ui/src/modal-manager.ts} +94 -64
- package/{src/ui → packages/harness-ui/src}/screen.ts +53 -26
- package/packages/harness-ui/src/surface.ts +252 -0
- package/packages/harness-ui/src/text-layout.ts +210 -0
- package/packages/nim-core/src/contracts.ts +239 -0
- package/packages/nim-core/src/event-store.ts +299 -0
- package/packages/nim-core/src/events.ts +53 -0
- package/packages/nim-core/src/index.ts +9 -0
- package/packages/nim-core/src/provider-router.ts +129 -0
- package/packages/nim-core/src/providers/anthropic-driver.ts +291 -0
- package/packages/nim-core/src/runtime-factory.ts +49 -0
- package/packages/nim-core/src/runtime.ts +1797 -0
- package/packages/nim-core/src/session-store.ts +516 -0
- package/packages/nim-core/src/telemetry.ts +48 -0
- package/packages/nim-test-tui/src/index.ts +150 -0
- package/packages/nim-ui-core/src/index.ts +1 -0
- package/packages/nim-ui-core/src/projection.ts +87 -0
- package/scripts/codex-live-mux-runtime.ts +2 -3721
- package/scripts/control-plane-daemon.ts +24 -2
- package/scripts/harness-bin.js +5 -0
- package/scripts/harness-commands.ts +300 -0
- package/scripts/harness-runtime.ts +82 -0
- package/scripts/harness.ts +33 -3007
- package/scripts/nim-tui-smoke.ts +748 -0
- package/src/cli/auth/runtime.ts +948 -0
- package/src/cli/default-gateway-pointer.ts +193 -0
- package/src/cli/gateway/runtime.ts +1872 -0
- package/src/cli/parsing/flags.ts +23 -0
- package/src/cli/parsing/session.ts +42 -0
- package/src/cli/runtime/context.ts +193 -0
- package/src/cli/runtime-app/application.ts +392 -0
- package/src/cli/runtime-infra/gateway-control.ts +729 -0
- package/{scripts/harness-inspector.ts → src/cli/workflows/inspector.ts} +14 -11
- package/src/cli/workflows/runtime.ts +965 -0
- package/src/clients/tui/left-rail-interactions.ts +519 -0
- package/src/clients/tui/main-pane-interactions.ts +509 -0
- package/src/clients/tui/modal-input-routing.ts +71 -0
- package/src/clients/tui/render-snapshot-adapter.ts +88 -0
- package/src/clients/web/synced-selectors.ts +132 -0
- package/src/codex/live-session.ts +82 -29
- package/src/config/config-core.ts +361 -10
- package/src/config/harness-paths.ts +4 -7
- package/src/config/harness-runtime-migration.ts +142 -19
- package/src/config/harness.config.template.jsonc +33 -0
- package/src/config/secrets-core.ts +92 -4
- package/src/control-plane/agent-realtime-api.ts +82 -427
- package/src/control-plane/prompt/thread-title-namer.ts +49 -23
- package/src/control-plane/session-summary.ts +10 -81
- package/src/control-plane/status/reducer-base.ts +12 -12
- package/src/control-plane/status/reducers/claude-status-reducer.ts +3 -3
- package/src/control-plane/status/reducers/codex-status-reducer.ts +4 -4
- package/src/control-plane/status/reducers/cursor-status-reducer.ts +3 -3
- package/src/control-plane/stream-client.ts +12 -2
- package/src/control-plane/stream-command-parser.ts +83 -143
- package/src/control-plane/stream-protocol.ts +53 -37
- package/src/control-plane/stream-server-background.ts +18 -2
- package/src/control-plane/stream-server-command.ts +376 -69
- package/src/control-plane/stream-server-session-runtime.ts +3 -2
- package/src/control-plane/stream-server.ts +943 -80
- package/src/control-plane/stream-session-runtime-types.ts +41 -0
- package/src/{mux/live-mux/control-plane-records.ts → core/contracts/records.ts} +24 -97
- package/src/core/state/observed-stream-cursor.ts +43 -0
- package/src/core/state/synced-observed-state.ts +273 -0
- package/src/core/store/harness-synced-store.ts +81 -0
- package/src/diff/budget.ts +136 -0
- package/src/diff/build.ts +289 -0
- package/src/diff/chunker.ts +146 -0
- package/src/diff/git-invoke.ts +315 -0
- package/src/diff/git-parse.ts +472 -0
- package/src/diff/hash.ts +70 -0
- package/src/diff/index.ts +24 -0
- package/src/diff/normalize.ts +134 -0
- package/src/diff/types.ts +178 -0
- package/src/diff-ui/args.ts +346 -0
- package/src/diff-ui/commands.ts +123 -0
- package/src/diff-ui/finder.ts +94 -0
- package/src/diff-ui/highlight.ts +127 -0
- package/src/diff-ui/index.ts +2 -0
- package/src/diff-ui/model.ts +141 -0
- package/src/diff-ui/pager.ts +412 -0
- package/src/diff-ui/render.ts +337 -0
- package/src/diff-ui/runtime.ts +379 -0
- package/src/diff-ui/state.ts +224 -0
- package/src/diff-ui/types.ts +236 -0
- package/src/domain/conversations.ts +11 -7
- package/src/domain/workspace.ts +76 -4
- package/src/mux/control-plane-op-queue.ts +93 -7
- package/src/mux/conversation-rail.ts +28 -71
- package/src/mux/dual-pane-core.ts +13 -13
- package/src/mux/harness-core-ui.ts +313 -42
- package/src/mux/input-shortcuts.ts +22 -112
- package/src/mux/keybinding-catalog.ts +340 -0
- package/src/mux/keybinding-registry.ts +103 -0
- package/src/mux/live-mux/command-menu-open-in.ts +280 -0
- package/src/mux/live-mux/command-menu.ts +167 -4
- package/src/mux/live-mux/conversation-state.ts +13 -0
- package/src/mux/live-mux/directory-resolution.ts +1 -1
- package/src/mux/live-mux/git-parsing.ts +16 -0
- package/src/mux/live-mux/git-snapshot.ts +33 -2
- package/src/mux/live-mux/global-shortcut-handlers.ts +6 -0
- package/src/mux/live-mux/home-pane-drop.ts +1 -1
- package/src/mux/live-mux/home-pane-pointer.ts +10 -0
- package/src/mux/live-mux/input-forwarding.ts +59 -2
- package/src/mux/live-mux/left-nav-activation.ts +124 -7
- package/src/mux/live-mux/left-nav.ts +35 -0
- package/src/mux/live-mux/link-click.ts +292 -0
- package/src/mux/live-mux/modal-command-menu-handler.ts +46 -9
- package/src/mux/live-mux/modal-conversation-handlers.ts +5 -1
- package/src/mux/live-mux/modal-input-reducers.ts +106 -8
- package/src/mux/live-mux/modal-overlays.ts +210 -31
- package/src/mux/live-mux/modal-pointer.ts +3 -7
- package/src/mux/live-mux/modal-prompt-handlers.ts +107 -1
- package/src/mux/live-mux/modal-release-notes-handler.ts +111 -0
- package/src/mux/live-mux/modal-task-editor-handler.ts +16 -11
- package/src/mux/live-mux/pointer-routing.ts +5 -2
- package/src/mux/live-mux/project-pane-pointer.ts +8 -0
- package/src/mux/live-mux/rail-layout.ts +33 -30
- package/src/mux/live-mux/release-notes.ts +383 -0
- package/src/mux/live-mux/render-trace-analysis.ts +52 -7
- package/src/mux/live-mux/repository-folding.ts +3 -0
- package/src/mux/live-mux/selection.ts +0 -4
- package/src/mux/live-mux/session-diagnostics-paths.ts +21 -0
- package/src/mux/project-pane-github-review.ts +271 -0
- package/src/mux/render-frame.ts +4 -0
- package/src/mux/runtime-app/codex-live-mux-runtime.ts +5191 -0
- package/src/mux/task-composer.ts +21 -14
- package/src/mux/task-focused-pane.ts +118 -117
- package/src/mux/task-screen-keybindings.ts +19 -82
- package/src/mux/workspace-rail-model.ts +270 -104
- package/src/mux/workspace-rail.ts +45 -22
- package/src/pty/session-broker.ts +1 -1
- package/{scripts → src/recording}/terminal-recording-gif-lib.ts +2 -2
- package/src/services/control-plane.ts +50 -32
- package/src/services/conversation-lifecycle.ts +118 -87
- package/src/services/conversation-startup-hydration.ts +20 -12
- package/src/services/directory-hydration.ts +21 -16
- package/src/services/event-persistence.ts +7 -0
- package/src/services/left-rail-pointer-handler.ts +329 -0
- package/src/services/mux-ui-state-persistence.ts +5 -1
- package/src/services/recording.ts +34 -26
- package/src/services/runtime-command-menu-agent-tools.ts +1 -1
- package/src/services/runtime-control-actions.ts +79 -61
- package/src/services/runtime-control-plane-ops.ts +122 -83
- package/src/services/runtime-conversation-actions.ts +40 -26
- package/src/services/runtime-conversation-activation.ts +82 -30
- package/src/services/runtime-conversation-starter.ts +80 -48
- package/src/services/runtime-conversation-title-edit.ts +91 -80
- package/src/services/runtime-envelope-handler.ts +107 -105
- package/src/services/runtime-git-state.ts +42 -29
- package/src/services/runtime-layout-resize.ts +3 -1
- package/src/services/runtime-left-rail-render.ts +99 -63
- package/src/services/runtime-nim-cli-session.ts +438 -0
- package/src/services/runtime-nim-session.ts +705 -0
- package/src/services/runtime-nim-tool-bridge.ts +141 -0
- package/src/services/runtime-observed-event-projection-pipeline.ts +45 -0
- package/src/services/runtime-process-wiring.ts +29 -36
- package/src/services/runtime-project-pane-github-review-cache.ts +164 -0
- package/src/services/runtime-render-flush.ts +63 -70
- package/src/services/runtime-render-lifecycle.ts +65 -64
- package/src/services/runtime-render-orchestrator.ts +55 -45
- package/src/services/runtime-render-pipeline.ts +106 -103
- package/src/services/runtime-render-state.ts +62 -49
- package/src/services/runtime-repository-actions.ts +97 -70
- package/src/services/runtime-right-pane-render.ts +80 -53
- package/src/services/runtime-shutdown.ts +38 -35
- package/src/services/runtime-stream-subscriptions.ts +35 -27
- package/src/services/runtime-task-composer-persistence.ts +71 -59
- package/src/services/runtime-task-composer-snapshot.ts +14 -0
- package/src/services/runtime-task-editor-actions.ts +46 -29
- package/src/services/runtime-task-pane-actions.ts +220 -134
- package/src/services/runtime-task-pane-shortcuts.ts +323 -123
- package/src/services/runtime-workspace-observed-effect-queue.ts +25 -0
- package/src/services/runtime-workspace-observed-events.ts +33 -184
- package/src/services/runtime-workspace-observed-transition-policy.ts +228 -0
- package/src/services/session-diagnostics-store.ts +217 -0
- package/src/services/startup-background-resume.ts +26 -21
- package/src/services/startup-orchestrator.ts +16 -13
- package/src/services/startup-paint-tracker.ts +29 -21
- package/src/services/startup-persisted-conversation-queue.ts +19 -13
- package/src/services/startup-settled-gate.ts +25 -15
- package/src/services/startup-shutdown.ts +18 -22
- package/src/services/startup-state-hydration.ts +44 -34
- package/src/services/startup-visibility.ts +12 -4
- package/src/services/task-pane-selection-actions.ts +89 -72
- package/src/services/task-planning-hydration.ts +24 -18
- package/src/services/task-planning-observed-events.ts +50 -52
- package/src/services/workspace-observed-events.ts +66 -63
- package/src/storage/storage-lifecycle-core.ts +438 -0
- package/src/store/control-plane-store-normalize.ts +33 -242
- package/src/store/control-plane-store-types.ts +1 -35
- package/src/store/control-plane-store.ts +396 -56
- package/src/store/event-store.ts +397 -3
- package/src/terminal/snapshot-oracle.ts +207 -94
- package/src/ui/mux-theme.ts +112 -8
- package/src/ui/panes/home-gridfire.ts +40 -31
- package/src/ui/panes/home.ts +10 -2
- package/src/ui/panes/nim.ts +315 -0
- package/src/mux/live-mux/actions-task.ts +0 -115
- package/src/mux/live-mux/left-rail-actions.ts +0 -118
- package/src/mux/live-mux/left-rail-conversation-click.ts +0 -82
- package/src/mux/live-mux/left-rail-pointer.ts +0 -74
- package/src/mux/live-mux/task-pane-shortcuts.ts +0 -206
- package/src/services/runtime-directory-actions.ts +0 -164
- package/src/services/runtime-input-pipeline.ts +0 -50
- package/src/services/runtime-input-router.ts +0 -189
- package/src/services/runtime-main-pane-input.ts +0 -230
- package/src/services/runtime-modal-input.ts +0 -119
- package/src/services/runtime-navigation-input.ts +0 -197
- package/src/services/runtime-rail-input.ts +0 -278
- package/src/services/runtime-task-pane.ts +0 -62
- package/src/services/runtime-workspace-actions.ts +0 -158
- package/src/ui/conversation-input-forwarder.ts +0 -114
- package/src/ui/conversation-selection-input.ts +0 -103
- package/src/ui/global-shortcut-input.ts +0 -89
- package/src/ui/input.ts +0 -238
- package/src/ui/kit.ts +0 -509
- package/src/ui/left-nav-input.ts +0 -80
- package/src/ui/left-rail-pointer-input.ts +0 -148
- package/src/ui/repository-fold-input.ts +0 -91
- package/src/ui/surface.ts +0 -224
|
@@ -0,0 +1,948 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomBytes, randomUUID, createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
5
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
|
+
import { loadHarnessConfig } from '../../config/config-core.ts';
|
|
7
|
+
import { resolveHarnessSecretsPath, upsertHarnessSecret } from '../../config/secrets-core.ts';
|
|
8
|
+
import { parsePositiveIntFlag, readCliValue } from '../parsing/flags.ts';
|
|
9
|
+
import { GatewayControlInfra } from '../runtime-infra/gateway-control.ts';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_AUTH_TIMEOUT_MS = 120_000;
|
|
12
|
+
const DEFAULT_GITHUB_DEVICE_BASE_URL = 'https://github.com';
|
|
13
|
+
const DEFAULT_LINEAR_AUTHORIZE_URL = 'https://linear.app/oauth/authorize';
|
|
14
|
+
const DEFAULT_LINEAR_TOKEN_URL = 'https://api.linear.app/oauth/token';
|
|
15
|
+
const DEFAULT_GITHUB_OAUTH_SCOPE = 'repo read:user';
|
|
16
|
+
const DEFAULT_LINEAR_OAUTH_SCOPE = 'read';
|
|
17
|
+
|
|
18
|
+
const GITHUB_OAUTH_ACCESS_TOKEN_KEY = 'HARNESS_GITHUB_OAUTH_ACCESS_TOKEN';
|
|
19
|
+
const GITHUB_OAUTH_REFRESH_TOKEN_KEY = 'HARNESS_GITHUB_OAUTH_REFRESH_TOKEN';
|
|
20
|
+
const GITHUB_OAUTH_EXPIRES_AT_KEY = 'HARNESS_GITHUB_OAUTH_ACCESS_EXPIRES_AT';
|
|
21
|
+
const LINEAR_OAUTH_ACCESS_TOKEN_KEY = 'HARNESS_LINEAR_OAUTH_ACCESS_TOKEN';
|
|
22
|
+
const LINEAR_OAUTH_REFRESH_TOKEN_KEY = 'HARNESS_LINEAR_OAUTH_REFRESH_TOKEN';
|
|
23
|
+
const LINEAR_OAUTH_EXPIRES_AT_KEY = 'HARNESS_LINEAR_OAUTH_ACCESS_EXPIRES_AT';
|
|
24
|
+
|
|
25
|
+
type AuthProvider = 'github' | 'linear';
|
|
26
|
+
type AuthProviderOrAll = AuthProvider | 'all';
|
|
27
|
+
|
|
28
|
+
interface ParsedAuthStatusCommand {
|
|
29
|
+
readonly type: 'status';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ParsedAuthLoginCommand {
|
|
33
|
+
readonly type: 'login';
|
|
34
|
+
readonly provider: AuthProvider;
|
|
35
|
+
readonly noBrowser: boolean;
|
|
36
|
+
readonly timeoutMs: number;
|
|
37
|
+
readonly scopes: string | null;
|
|
38
|
+
readonly callbackPort: number | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ParsedAuthRefreshCommand {
|
|
42
|
+
readonly type: 'refresh';
|
|
43
|
+
readonly provider: AuthProviderOrAll;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface ParsedAuthLogoutCommand {
|
|
47
|
+
readonly type: 'logout';
|
|
48
|
+
readonly provider: AuthProviderOrAll;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type ParsedAuthCommand =
|
|
52
|
+
| ParsedAuthStatusCommand
|
|
53
|
+
| ParsedAuthLoginCommand
|
|
54
|
+
| ParsedAuthRefreshCommand
|
|
55
|
+
| ParsedAuthLogoutCommand;
|
|
56
|
+
|
|
57
|
+
interface RefreshLinearOauthTokenOptions {
|
|
58
|
+
readonly force: boolean;
|
|
59
|
+
readonly timeoutMs: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface RefreshLinearOauthTokenResult {
|
|
63
|
+
readonly refreshed: boolean;
|
|
64
|
+
readonly skippedReason: string | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseOauthCallbackPort(value: string, label: string): number {
|
|
68
|
+
const trimmed = value.trim();
|
|
69
|
+
if (!/^\d+$/u.test(trimmed)) {
|
|
70
|
+
throw new Error(`invalid ${label} value: ${value}`);
|
|
71
|
+
}
|
|
72
|
+
const parsed = Number.parseInt(trimmed, 10);
|
|
73
|
+
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65_535) {
|
|
74
|
+
throw new Error(`invalid ${label} value: ${value}`);
|
|
75
|
+
}
|
|
76
|
+
return parsed;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
class AuthCommandParser {
|
|
80
|
+
public constructor() {}
|
|
81
|
+
|
|
82
|
+
private parseAuthProvider(value: string, allowAll: boolean): AuthProviderOrAll {
|
|
83
|
+
const normalized = value.trim().toLowerCase();
|
|
84
|
+
if (normalized === 'github' || normalized === 'linear') {
|
|
85
|
+
return normalized;
|
|
86
|
+
}
|
|
87
|
+
if (allowAll && normalized === 'all') {
|
|
88
|
+
return 'all';
|
|
89
|
+
}
|
|
90
|
+
throw new Error(`unsupported auth provider: ${value}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private parseLoginCommand(argv: readonly string[]): ParsedAuthLoginCommand {
|
|
94
|
+
if (argv.length === 0) {
|
|
95
|
+
throw new Error('missing auth login provider (expected: github|linear)');
|
|
96
|
+
}
|
|
97
|
+
const provider = this.parseAuthProvider(argv[0]!, false);
|
|
98
|
+
if (provider === 'all') {
|
|
99
|
+
throw new Error('auth login requires a single provider (github|linear)');
|
|
100
|
+
}
|
|
101
|
+
let noBrowser = false;
|
|
102
|
+
let timeoutMs = DEFAULT_AUTH_TIMEOUT_MS;
|
|
103
|
+
let scopes: string | null = null;
|
|
104
|
+
let callbackPort: number | null = null;
|
|
105
|
+
for (let index = 1; index < argv.length; index += 1) {
|
|
106
|
+
const arg = argv[index]!;
|
|
107
|
+
if (arg === '--no-browser') {
|
|
108
|
+
noBrowser = true;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (arg === '--timeout-ms') {
|
|
112
|
+
timeoutMs = parsePositiveIntFlag(readCliValue(argv, index, '--timeout-ms'), '--timeout-ms');
|
|
113
|
+
index += 1;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (arg === '--scopes') {
|
|
117
|
+
scopes = readCliValue(argv, index, '--scopes');
|
|
118
|
+
index += 1;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (arg === '--callback-port') {
|
|
122
|
+
callbackPort = parseOauthCallbackPort(
|
|
123
|
+
readCliValue(argv, index, '--callback-port'),
|
|
124
|
+
'--callback-port',
|
|
125
|
+
);
|
|
126
|
+
index += 1;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
throw new Error(`unknown auth login option: ${arg}`);
|
|
130
|
+
}
|
|
131
|
+
if (provider !== 'linear' && callbackPort !== null) {
|
|
132
|
+
throw new Error('--callback-port is only supported for auth login linear');
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
type: 'login',
|
|
136
|
+
provider,
|
|
137
|
+
noBrowser,
|
|
138
|
+
timeoutMs,
|
|
139
|
+
scopes,
|
|
140
|
+
callbackPort,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private parseProviderSelectorCommand(
|
|
145
|
+
type: 'refresh' | 'logout',
|
|
146
|
+
argv: readonly string[],
|
|
147
|
+
): ParsedAuthRefreshCommand | ParsedAuthLogoutCommand {
|
|
148
|
+
if (argv.length > 1) {
|
|
149
|
+
throw new Error(`unknown auth ${type} option: ${argv[1]}`);
|
|
150
|
+
}
|
|
151
|
+
const provider = argv.length === 0 ? 'all' : this.parseAuthProvider(argv[0]!, true);
|
|
152
|
+
return {
|
|
153
|
+
type,
|
|
154
|
+
provider,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
public parse(argv: readonly string[]): ParsedAuthCommand {
|
|
159
|
+
if (argv.length === 0) {
|
|
160
|
+
return { type: 'status' };
|
|
161
|
+
}
|
|
162
|
+
const subcommand = argv[0]!;
|
|
163
|
+
if (subcommand === 'status') {
|
|
164
|
+
if (argv.length > 1) {
|
|
165
|
+
throw new Error(`unknown auth status option: ${argv[1]}`);
|
|
166
|
+
}
|
|
167
|
+
return { type: 'status' };
|
|
168
|
+
}
|
|
169
|
+
if (subcommand === 'login') {
|
|
170
|
+
return this.parseLoginCommand(argv.slice(1));
|
|
171
|
+
}
|
|
172
|
+
if (subcommand === 'refresh') {
|
|
173
|
+
return this.parseProviderSelectorCommand('refresh', argv.slice(1));
|
|
174
|
+
}
|
|
175
|
+
if (subcommand === 'logout') {
|
|
176
|
+
return this.parseProviderSelectorCommand('logout', argv.slice(1));
|
|
177
|
+
}
|
|
178
|
+
throw new Error(`unknown auth subcommand: ${subcommand}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export class AuthRuntimeService {
|
|
183
|
+
public readonly parser = new AuthCommandParser();
|
|
184
|
+
private readonly infra: GatewayControlInfra;
|
|
185
|
+
|
|
186
|
+
constructor(
|
|
187
|
+
private readonly invocationDirectory: string,
|
|
188
|
+
private readonly env: NodeJS.ProcessEnv = process.env,
|
|
189
|
+
private readonly writeStdout: (text: string) => void = (text) => {
|
|
190
|
+
process.stdout.write(text);
|
|
191
|
+
},
|
|
192
|
+
private readonly writeStderr: (text: string) => void = (text) => {
|
|
193
|
+
process.stderr.write(text);
|
|
194
|
+
},
|
|
195
|
+
) {
|
|
196
|
+
this.infra = new GatewayControlInfra({ env: this.env, cwd: this.invocationDirectory });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
public parseCommand(argv: readonly string[]): ParsedAuthCommand {
|
|
200
|
+
return this.parser.parse(argv);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
public async run(argv: readonly string[]): Promise<number> {
|
|
204
|
+
const command = this.parseCommand(argv);
|
|
205
|
+
const tokenEnvVars = this.resolveIntegrationTokenEnvVars();
|
|
206
|
+
if (command.type === 'status') {
|
|
207
|
+
this.writeStdout(
|
|
208
|
+
`${this.formatAuthProviderStatusLine(
|
|
209
|
+
'github',
|
|
210
|
+
tokenEnvVars.githubTokenEnvVar,
|
|
211
|
+
GITHUB_OAUTH_ACCESS_TOKEN_KEY,
|
|
212
|
+
)}\n`,
|
|
213
|
+
);
|
|
214
|
+
this.writeStdout(
|
|
215
|
+
`${this.formatAuthProviderStatusLine(
|
|
216
|
+
'linear',
|
|
217
|
+
tokenEnvVars.linearTokenEnvVar,
|
|
218
|
+
LINEAR_OAUTH_ACCESS_TOKEN_KEY,
|
|
219
|
+
)}\n`,
|
|
220
|
+
);
|
|
221
|
+
return 0;
|
|
222
|
+
}
|
|
223
|
+
if (command.type === 'login') {
|
|
224
|
+
if (command.provider === 'github') {
|
|
225
|
+
await this.loginGitHubWithOAuthDeviceFlow(command);
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
await this.loginLinearWithOAuthPkce(command);
|
|
229
|
+
return 0;
|
|
230
|
+
}
|
|
231
|
+
if (command.type === 'refresh') {
|
|
232
|
+
const providers: readonly AuthProvider[] =
|
|
233
|
+
command.provider === 'all' ? ['github', 'linear'] : [command.provider];
|
|
234
|
+
let hadFailure = false;
|
|
235
|
+
for (const provider of providers) {
|
|
236
|
+
if (provider === 'github') {
|
|
237
|
+
this.writeStdout(
|
|
238
|
+
'github oauth refresh: skipped (device-flow tokens do not guarantee refresh support)\n',
|
|
239
|
+
);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
const result = await this.refreshLinearOauthToken({
|
|
244
|
+
force: true,
|
|
245
|
+
timeoutMs: DEFAULT_AUTH_TIMEOUT_MS,
|
|
246
|
+
});
|
|
247
|
+
if (result.refreshed) {
|
|
248
|
+
this.writeStdout('linear oauth refresh: refreshed\n');
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
this.writeStdout(
|
|
252
|
+
`linear oauth refresh: skipped (${result.skippedReason ?? 'unknown'})\n`,
|
|
253
|
+
);
|
|
254
|
+
} catch (error: unknown) {
|
|
255
|
+
hadFailure = true;
|
|
256
|
+
this.writeStderr(
|
|
257
|
+
`linear oauth refresh failed: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return hadFailure ? 1 : 0;
|
|
262
|
+
}
|
|
263
|
+
const providers: readonly AuthProvider[] =
|
|
264
|
+
command.provider === 'all' ? ['github', 'linear'] : [command.provider];
|
|
265
|
+
const keysToRemove: string[] = [];
|
|
266
|
+
for (const provider of providers) {
|
|
267
|
+
if (provider === 'github') {
|
|
268
|
+
keysToRemove.push(
|
|
269
|
+
GITHUB_OAUTH_ACCESS_TOKEN_KEY,
|
|
270
|
+
GITHUB_OAUTH_REFRESH_TOKEN_KEY,
|
|
271
|
+
GITHUB_OAUTH_EXPIRES_AT_KEY,
|
|
272
|
+
);
|
|
273
|
+
} else {
|
|
274
|
+
keysToRemove.push(
|
|
275
|
+
LINEAR_OAUTH_ACCESS_TOKEN_KEY,
|
|
276
|
+
LINEAR_OAUTH_REFRESH_TOKEN_KEY,
|
|
277
|
+
LINEAR_OAUTH_EXPIRES_AT_KEY,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const removed = this.removeHarnessSecrets(keysToRemove);
|
|
282
|
+
this.writeStdout(
|
|
283
|
+
`auth logout complete: providers=${providers.join(',')} removed=${String(removed.removedCount)} file=${removed.filePath}\n`,
|
|
284
|
+
);
|
|
285
|
+
return 0;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
public async refreshLinearOauthTokenBeforeGatewayStart(): Promise<void> {
|
|
289
|
+
try {
|
|
290
|
+
const tokenEnvVars = this.resolveIntegrationTokenEnvVars();
|
|
291
|
+
const manualToken = this.env[tokenEnvVars.linearTokenEnvVar];
|
|
292
|
+
if (typeof manualToken === 'string' && manualToken.trim().length > 0) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const result = await this.refreshLinearOauthToken({
|
|
296
|
+
force: false,
|
|
297
|
+
timeoutMs: 10_000,
|
|
298
|
+
});
|
|
299
|
+
if (result.refreshed) {
|
|
300
|
+
this.writeStdout('[auth] linear oauth token refreshed before gateway start\n');
|
|
301
|
+
}
|
|
302
|
+
} catch (error: unknown) {
|
|
303
|
+
this.writeStderr(
|
|
304
|
+
`[auth] linear oauth refresh skipped: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private normalizeScopeList(rawValue: string | null | undefined, fallback: string): string {
|
|
310
|
+
const candidate = typeof rawValue === 'string' ? rawValue : fallback;
|
|
311
|
+
const tokens = candidate
|
|
312
|
+
.split(/[\s,]+/u)
|
|
313
|
+
.map((token) => token.trim())
|
|
314
|
+
.filter((token) => token.length > 0);
|
|
315
|
+
if (tokens.length === 0) {
|
|
316
|
+
return fallback;
|
|
317
|
+
}
|
|
318
|
+
return [...new Set(tokens)].join(' ');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private readRequiredEnvValue(key: string): string {
|
|
322
|
+
const value = this.env[key];
|
|
323
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
324
|
+
throw new Error(`missing required ${key}`);
|
|
325
|
+
}
|
|
326
|
+
return value.trim();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private parseOptionalIsoTimestamp(value: string | null | undefined): number | null {
|
|
330
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
const parsed = Date.parse(value);
|
|
334
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private isTokenNearExpiry(expiresAtMs: number | null, skewMs = 60_000): boolean {
|
|
338
|
+
if (expiresAtMs === null) {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
return Date.now() + skewMs >= expiresAtMs;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private expiresAtFromExpiresIn(rawExpiresIn: unknown): string | null {
|
|
345
|
+
if (typeof rawExpiresIn !== 'number' || !Number.isFinite(rawExpiresIn) || rawExpiresIn <= 0) {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
const expiresMs = Math.floor(rawExpiresIn * 1000);
|
|
349
|
+
return new Date(Date.now() + expiresMs).toISOString();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private parseSecretLineKeyForDeletion(line: string): string | null {
|
|
353
|
+
const trimmed = line.trim();
|
|
354
|
+
if (trimmed.length === 0 || trimmed.startsWith('#')) {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
const withoutExport = trimmed.startsWith('export ')
|
|
358
|
+
? trimmed.slice('export '.length).trimStart()
|
|
359
|
+
: trimmed;
|
|
360
|
+
const equalIndex = withoutExport.indexOf('=');
|
|
361
|
+
if (equalIndex <= 0) {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
return withoutExport.slice(0, equalIndex).trim();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private removeHarnessSecrets(keys: readonly string[]): {
|
|
368
|
+
filePath: string;
|
|
369
|
+
removedCount: number;
|
|
370
|
+
} {
|
|
371
|
+
const uniqueKeys = [...new Set(keys)].filter((key) => key.length > 0);
|
|
372
|
+
const filePath = resolveHarnessSecretsPath(this.invocationDirectory, undefined, this.env);
|
|
373
|
+
if (uniqueKeys.length === 0) {
|
|
374
|
+
return {
|
|
375
|
+
filePath,
|
|
376
|
+
removedCount: 0,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
let removedCount = 0;
|
|
380
|
+
if (existsSync(filePath)) {
|
|
381
|
+
const sourceText = readFileSync(filePath, 'utf8');
|
|
382
|
+
const sourceLines = sourceText.split(/\r?\n/u);
|
|
383
|
+
const nextLines: string[] = [];
|
|
384
|
+
for (const line of sourceLines) {
|
|
385
|
+
const key = this.parseSecretLineKeyForDeletion(line);
|
|
386
|
+
if (key !== null && uniqueKeys.includes(key)) {
|
|
387
|
+
removedCount += 1;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
nextLines.push(line);
|
|
391
|
+
}
|
|
392
|
+
while (nextLines.length > 0 && nextLines[nextLines.length - 1] === '') {
|
|
393
|
+
nextLines.pop();
|
|
394
|
+
}
|
|
395
|
+
this.infra.writeTextFileAtomically(filePath, `${nextLines.join('\n')}\n`);
|
|
396
|
+
}
|
|
397
|
+
for (const key of uniqueKeys) {
|
|
398
|
+
delete this.env[key];
|
|
399
|
+
delete process.env[key];
|
|
400
|
+
}
|
|
401
|
+
return {
|
|
402
|
+
filePath,
|
|
403
|
+
removedCount,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private upsertHarnessSecretValue(key: string, value: string): void {
|
|
408
|
+
upsertHarnessSecret({
|
|
409
|
+
cwd: this.invocationDirectory,
|
|
410
|
+
env: this.env,
|
|
411
|
+
key,
|
|
412
|
+
value,
|
|
413
|
+
});
|
|
414
|
+
this.env[key] = value;
|
|
415
|
+
process.env[key] = value;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private tryOpenBrowserUrl(url: string): void {
|
|
419
|
+
const commandOverride =
|
|
420
|
+
typeof this.env.HARNESS_AUTH_BROWSER_COMMAND === 'string' &&
|
|
421
|
+
this.env.HARNESS_AUTH_BROWSER_COMMAND.trim().length > 0
|
|
422
|
+
? this.env.HARNESS_AUTH_BROWSER_COMMAND.trim()
|
|
423
|
+
: null;
|
|
424
|
+
const child =
|
|
425
|
+
commandOverride !== null
|
|
426
|
+
? spawn(commandOverride, [url], {
|
|
427
|
+
detached: true,
|
|
428
|
+
stdio: 'ignore',
|
|
429
|
+
env: this.env,
|
|
430
|
+
})
|
|
431
|
+
: process.platform === 'darwin'
|
|
432
|
+
? spawn('open', [url], {
|
|
433
|
+
detached: true,
|
|
434
|
+
stdio: 'ignore',
|
|
435
|
+
env: this.env,
|
|
436
|
+
})
|
|
437
|
+
: process.platform === 'win32'
|
|
438
|
+
? spawn('cmd', ['/c', 'start', '', url], {
|
|
439
|
+
detached: true,
|
|
440
|
+
stdio: 'ignore',
|
|
441
|
+
env: this.env,
|
|
442
|
+
})
|
|
443
|
+
: spawn('xdg-open', [url], {
|
|
444
|
+
detached: true,
|
|
445
|
+
stdio: 'ignore',
|
|
446
|
+
env: this.env,
|
|
447
|
+
});
|
|
448
|
+
child.once('error', () => undefined);
|
|
449
|
+
child.unref();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private async fetchJsonRecord(
|
|
453
|
+
url: string,
|
|
454
|
+
init: RequestInit & { timeoutMs?: number } = {},
|
|
455
|
+
): Promise<Record<string, unknown>> {
|
|
456
|
+
const timeoutMs = init.timeoutMs ?? 15_000;
|
|
457
|
+
const controller = new AbortController();
|
|
458
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
459
|
+
timer.unref();
|
|
460
|
+
let response: Response;
|
|
461
|
+
try {
|
|
462
|
+
response = await fetch(url, {
|
|
463
|
+
...init,
|
|
464
|
+
signal: controller.signal,
|
|
465
|
+
});
|
|
466
|
+
} finally {
|
|
467
|
+
clearTimeout(timer);
|
|
468
|
+
}
|
|
469
|
+
const text = await response.text();
|
|
470
|
+
let parsed: unknown = {};
|
|
471
|
+
if (text.trim().length > 0) {
|
|
472
|
+
try {
|
|
473
|
+
parsed = JSON.parse(text);
|
|
474
|
+
} catch (error: unknown) {
|
|
475
|
+
throw new Error(
|
|
476
|
+
`oauth endpoint returned non-json payload (${response.status}): ${error instanceof Error ? error.message : String(error)}`,
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (!response.ok) {
|
|
481
|
+
const message =
|
|
482
|
+
typeof parsed === 'object' &&
|
|
483
|
+
parsed !== null &&
|
|
484
|
+
typeof (parsed as Record<string, unknown>)['error_description'] === 'string'
|
|
485
|
+
? ((parsed as Record<string, unknown>)['error_description'] as string)
|
|
486
|
+
: text || response.statusText;
|
|
487
|
+
throw new Error(`oauth request failed (${response.status}): ${message}`);
|
|
488
|
+
}
|
|
489
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
490
|
+
throw new Error('oauth endpoint returned malformed payload');
|
|
491
|
+
}
|
|
492
|
+
return parsed as Record<string, unknown>;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private asStringField(record: Record<string, unknown>, key: string): string | null {
|
|
496
|
+
const value = record[key];
|
|
497
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private resolveGitHubDeviceCodeUrl(): string {
|
|
501
|
+
if (
|
|
502
|
+
typeof this.env.HARNESS_GITHUB_OAUTH_DEVICE_CODE_URL === 'string' &&
|
|
503
|
+
this.env.HARNESS_GITHUB_OAUTH_DEVICE_CODE_URL.trim().length > 0
|
|
504
|
+
) {
|
|
505
|
+
return this.env.HARNESS_GITHUB_OAUTH_DEVICE_CODE_URL.trim();
|
|
506
|
+
}
|
|
507
|
+
const base =
|
|
508
|
+
typeof this.env.HARNESS_GITHUB_OAUTH_BASE_URL === 'string' &&
|
|
509
|
+
this.env.HARNESS_GITHUB_OAUTH_BASE_URL.trim().length > 0
|
|
510
|
+
? this.env.HARNESS_GITHUB_OAUTH_BASE_URL.trim().replace(/\/+$/u, '')
|
|
511
|
+
: DEFAULT_GITHUB_DEVICE_BASE_URL;
|
|
512
|
+
return `${base}/login/device/code`;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
private resolveGitHubOauthTokenUrl(): string {
|
|
516
|
+
if (
|
|
517
|
+
typeof this.env.HARNESS_GITHUB_OAUTH_TOKEN_URL === 'string' &&
|
|
518
|
+
this.env.HARNESS_GITHUB_OAUTH_TOKEN_URL.trim().length > 0
|
|
519
|
+
) {
|
|
520
|
+
return this.env.HARNESS_GITHUB_OAUTH_TOKEN_URL.trim();
|
|
521
|
+
}
|
|
522
|
+
const base =
|
|
523
|
+
typeof this.env.HARNESS_GITHUB_OAUTH_BASE_URL === 'string' &&
|
|
524
|
+
this.env.HARNESS_GITHUB_OAUTH_BASE_URL.trim().length > 0
|
|
525
|
+
? this.env.HARNESS_GITHUB_OAUTH_BASE_URL.trim().replace(/\/+$/u, '')
|
|
526
|
+
: DEFAULT_GITHUB_DEVICE_BASE_URL;
|
|
527
|
+
return `${base}/login/oauth/access_token`;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private resolveLinearAuthorizeUrl(): string {
|
|
531
|
+
if (
|
|
532
|
+
typeof this.env.HARNESS_LINEAR_OAUTH_AUTHORIZE_URL === 'string' &&
|
|
533
|
+
this.env.HARNESS_LINEAR_OAUTH_AUTHORIZE_URL.trim().length > 0
|
|
534
|
+
) {
|
|
535
|
+
return this.env.HARNESS_LINEAR_OAUTH_AUTHORIZE_URL.trim();
|
|
536
|
+
}
|
|
537
|
+
return DEFAULT_LINEAR_AUTHORIZE_URL;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
private resolveLinearTokenUrl(): string {
|
|
541
|
+
if (
|
|
542
|
+
typeof this.env.HARNESS_LINEAR_OAUTH_TOKEN_URL === 'string' &&
|
|
543
|
+
this.env.HARNESS_LINEAR_OAUTH_TOKEN_URL.trim().length > 0
|
|
544
|
+
) {
|
|
545
|
+
return this.env.HARNESS_LINEAR_OAUTH_TOKEN_URL.trim();
|
|
546
|
+
}
|
|
547
|
+
return DEFAULT_LINEAR_TOKEN_URL;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private resolveLinearOauthCallbackPort(command: ParsedAuthLoginCommand): number {
|
|
551
|
+
if (command.callbackPort !== null) {
|
|
552
|
+
return command.callbackPort;
|
|
553
|
+
}
|
|
554
|
+
const configured = this.env.HARNESS_LINEAR_OAUTH_CALLBACK_PORT;
|
|
555
|
+
if (typeof configured !== 'string' || configured.trim().length === 0) {
|
|
556
|
+
return 0;
|
|
557
|
+
}
|
|
558
|
+
return parseOauthCallbackPort(configured, 'HARNESS_LINEAR_OAUTH_CALLBACK_PORT');
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private createPkceVerifier(): string {
|
|
562
|
+
return randomBytes(32).toString('base64url');
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
private createPkceChallenge(verifier: string): string {
|
|
566
|
+
return createHash('sha256').update(verifier).digest('base64url');
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
private resolveIntegrationTokenEnvVars(): {
|
|
570
|
+
githubTokenEnvVar: string;
|
|
571
|
+
linearTokenEnvVar: string;
|
|
572
|
+
} {
|
|
573
|
+
const loaded = loadHarnessConfig({
|
|
574
|
+
cwd: this.invocationDirectory,
|
|
575
|
+
env: this.env,
|
|
576
|
+
});
|
|
577
|
+
return {
|
|
578
|
+
githubTokenEnvVar: loaded.config.github.tokenEnvVar,
|
|
579
|
+
linearTokenEnvVar: loaded.config.linear.tokenEnvVar,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
private async loginGitHubWithOAuthDeviceFlow(command: ParsedAuthLoginCommand): Promise<void> {
|
|
584
|
+
const clientId = this.readRequiredEnvValue('HARNESS_GITHUB_OAUTH_CLIENT_ID');
|
|
585
|
+
const clientSecret =
|
|
586
|
+
typeof this.env.HARNESS_GITHUB_OAUTH_CLIENT_SECRET === 'string' &&
|
|
587
|
+
this.env.HARNESS_GITHUB_OAUTH_CLIENT_SECRET.trim().length > 0
|
|
588
|
+
? this.env.HARNESS_GITHUB_OAUTH_CLIENT_SECRET.trim()
|
|
589
|
+
: null;
|
|
590
|
+
const scopes = this.normalizeScopeList(
|
|
591
|
+
command.scopes ?? this.env.HARNESS_GITHUB_OAUTH_SCOPES,
|
|
592
|
+
DEFAULT_GITHUB_OAUTH_SCOPE,
|
|
593
|
+
);
|
|
594
|
+
const deviceCodePayload = await this.fetchJsonRecord(this.resolveGitHubDeviceCodeUrl(), {
|
|
595
|
+
method: 'POST',
|
|
596
|
+
timeoutMs: command.timeoutMs,
|
|
597
|
+
headers: {
|
|
598
|
+
Accept: 'application/json',
|
|
599
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
600
|
+
},
|
|
601
|
+
body: new URLSearchParams({
|
|
602
|
+
client_id: clientId,
|
|
603
|
+
scope: scopes,
|
|
604
|
+
}).toString(),
|
|
605
|
+
});
|
|
606
|
+
const deviceCode = this.asStringField(deviceCodePayload, 'device_code');
|
|
607
|
+
const userCode = this.asStringField(deviceCodePayload, 'user_code');
|
|
608
|
+
const verificationUri = this.asStringField(deviceCodePayload, 'verification_uri');
|
|
609
|
+
const verificationUriComplete =
|
|
610
|
+
this.asStringField(deviceCodePayload, 'verification_uri_complete') ?? verificationUri;
|
|
611
|
+
const expiresInRaw = deviceCodePayload['expires_in'];
|
|
612
|
+
const expiresIn =
|
|
613
|
+
typeof expiresInRaw === 'number' && Number.isFinite(expiresInRaw) && expiresInRaw > 0
|
|
614
|
+
? expiresInRaw
|
|
615
|
+
: 900;
|
|
616
|
+
const intervalRaw = deviceCodePayload['interval'];
|
|
617
|
+
let intervalMs =
|
|
618
|
+
typeof intervalRaw === 'number' && Number.isFinite(intervalRaw) && intervalRaw > 0
|
|
619
|
+
? Math.floor(intervalRaw * 1000)
|
|
620
|
+
: 5000;
|
|
621
|
+
if (deviceCode === null || userCode === null || verificationUri === null) {
|
|
622
|
+
throw new Error('github oauth device-code response malformed');
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
this.writeStdout(`github oauth user code: ${userCode}\n`);
|
|
626
|
+
this.writeStdout(`github oauth verify url: ${verificationUri}\n`);
|
|
627
|
+
if (verificationUriComplete !== null) {
|
|
628
|
+
this.writeStdout(`github oauth direct url: ${verificationUriComplete}\n`);
|
|
629
|
+
if (!command.noBrowser) {
|
|
630
|
+
this.tryOpenBrowserUrl(verificationUriComplete);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const pollDeadlineMs = Date.now() + Math.min(command.timeoutMs, Math.floor(expiresIn * 1000));
|
|
635
|
+
let tokenPayload: Record<string, unknown> | null = null;
|
|
636
|
+
while (Date.now() <= pollDeadlineMs) {
|
|
637
|
+
await delay(intervalMs);
|
|
638
|
+
const candidatePayload = await this.fetchJsonRecord(this.resolveGitHubOauthTokenUrl(), {
|
|
639
|
+
method: 'POST',
|
|
640
|
+
timeoutMs: command.timeoutMs,
|
|
641
|
+
headers: {
|
|
642
|
+
Accept: 'application/json',
|
|
643
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
644
|
+
},
|
|
645
|
+
body: new URLSearchParams({
|
|
646
|
+
client_id: clientId,
|
|
647
|
+
...(clientSecret === null ? {} : { client_secret: clientSecret }),
|
|
648
|
+
device_code: deviceCode,
|
|
649
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
650
|
+
}).toString(),
|
|
651
|
+
});
|
|
652
|
+
const accessToken = this.asStringField(candidatePayload, 'access_token');
|
|
653
|
+
if (accessToken !== null) {
|
|
654
|
+
tokenPayload = candidatePayload;
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
const errorCode = this.asStringField(candidatePayload, 'error');
|
|
658
|
+
if (errorCode === 'authorization_pending') {
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
if (errorCode === 'slow_down') {
|
|
662
|
+
intervalMs = Math.min(30_000, intervalMs + 5000);
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
if (errorCode !== null) {
|
|
666
|
+
const description =
|
|
667
|
+
this.asStringField(candidatePayload, 'error_description') ??
|
|
668
|
+
this.asStringField(candidatePayload, 'error_uri') ??
|
|
669
|
+
'oauth device login failed';
|
|
670
|
+
throw new Error(`github oauth login failed (${errorCode}): ${description}`);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
if (tokenPayload === null) {
|
|
674
|
+
throw new Error('timed out waiting for github oauth authorization');
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const accessToken = this.asStringField(tokenPayload, 'access_token');
|
|
678
|
+
if (accessToken === null) {
|
|
679
|
+
throw new Error('github oauth token response missing access_token');
|
|
680
|
+
}
|
|
681
|
+
const refreshToken = this.asStringField(tokenPayload, 'refresh_token');
|
|
682
|
+
const expiresAt = this.expiresAtFromExpiresIn(tokenPayload['expires_in']);
|
|
683
|
+
this.upsertHarnessSecretValue('HARNESS_GITHUB_OAUTH_CLIENT_ID', clientId);
|
|
684
|
+
this.upsertHarnessSecretValue(GITHUB_OAUTH_ACCESS_TOKEN_KEY, accessToken);
|
|
685
|
+
if (refreshToken !== null) {
|
|
686
|
+
this.upsertHarnessSecretValue(GITHUB_OAUTH_REFRESH_TOKEN_KEY, refreshToken);
|
|
687
|
+
} else {
|
|
688
|
+
this.removeHarnessSecrets([GITHUB_OAUTH_REFRESH_TOKEN_KEY]);
|
|
689
|
+
}
|
|
690
|
+
if (expiresAt !== null) {
|
|
691
|
+
this.upsertHarnessSecretValue(GITHUB_OAUTH_EXPIRES_AT_KEY, expiresAt);
|
|
692
|
+
} else {
|
|
693
|
+
this.removeHarnessSecrets([GITHUB_OAUTH_EXPIRES_AT_KEY]);
|
|
694
|
+
}
|
|
695
|
+
this.writeStdout(
|
|
696
|
+
`github oauth login complete: token saved to ${GITHUB_OAUTH_ACCESS_TOKEN_KEY}\n`,
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
private async waitForLinearOauthCodeViaCallback(
|
|
701
|
+
clientId: string,
|
|
702
|
+
scopes: string,
|
|
703
|
+
timeoutMs: number,
|
|
704
|
+
noBrowser: boolean,
|
|
705
|
+
preferredCallbackPort: number,
|
|
706
|
+
): Promise<{ code: string; redirectUri: string; verifier: string }> {
|
|
707
|
+
const verifier = this.createPkceVerifier();
|
|
708
|
+
const challenge = this.createPkceChallenge(verifier);
|
|
709
|
+
const state = randomUUID();
|
|
710
|
+
const authorizeBaseUrl = this.resolveLinearAuthorizeUrl();
|
|
711
|
+
return await new Promise<{ code: string; redirectUri: string; verifier: string }>(
|
|
712
|
+
(resolveCode, rejectCode) => {
|
|
713
|
+
let settled = false;
|
|
714
|
+
let attemptedDynamicFallback = preferredCallbackPort === 0;
|
|
715
|
+
const server = createHttpServer((request, response) => {
|
|
716
|
+
const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
717
|
+
if (requestUrl.pathname !== '/oauth/callback') {
|
|
718
|
+
response.statusCode = 404;
|
|
719
|
+
response.end('Not found');
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
const code = requestUrl.searchParams.get('code');
|
|
723
|
+
const receivedState = requestUrl.searchParams.get('state');
|
|
724
|
+
if (receivedState !== state) {
|
|
725
|
+
response.statusCode = 400;
|
|
726
|
+
response.end('State mismatch');
|
|
727
|
+
finish(new Error('linear oauth callback state mismatch'));
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
if (code === null || code.trim().length === 0) {
|
|
731
|
+
response.statusCode = 400;
|
|
732
|
+
response.end('Missing code');
|
|
733
|
+
finish(new Error('linear oauth callback missing code'));
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
response.statusCode = 200;
|
|
737
|
+
response.setHeader('content-type', 'text/html; charset=utf-8');
|
|
738
|
+
response.end(
|
|
739
|
+
'<html><body><h3>Harness Linear OAuth complete</h3><p>You can close this tab.</p></body></html>',
|
|
740
|
+
);
|
|
741
|
+
finish(null, {
|
|
742
|
+
code: code.trim(),
|
|
743
|
+
redirectUri,
|
|
744
|
+
verifier,
|
|
745
|
+
});
|
|
746
|
+
});
|
|
747
|
+
const finish = (
|
|
748
|
+
error: Error | null,
|
|
749
|
+
result?: { code: string; redirectUri: string; verifier: string },
|
|
750
|
+
): void => {
|
|
751
|
+
if (settled) {
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
settled = true;
|
|
755
|
+
clearTimeout(timeoutHandle);
|
|
756
|
+
server.off('error', onListenError);
|
|
757
|
+
server.close(() => {
|
|
758
|
+
if (error !== null) {
|
|
759
|
+
rejectCode(error);
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
if (result === undefined) {
|
|
763
|
+
rejectCode(new Error('linear oauth callback failed without result'));
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
resolveCode(result);
|
|
767
|
+
});
|
|
768
|
+
};
|
|
769
|
+
const onListening = (): void => {
|
|
770
|
+
const address = server.address();
|
|
771
|
+
if (address === null || typeof address === 'string') {
|
|
772
|
+
finish(new Error('unable to determine linear oauth callback address'));
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
redirectUri = `http://127.0.0.1:${String(address.port)}/oauth/callback`;
|
|
776
|
+
const authorizeUrl = new URL(authorizeBaseUrl);
|
|
777
|
+
authorizeUrl.searchParams.set('response_type', 'code');
|
|
778
|
+
authorizeUrl.searchParams.set('client_id', clientId);
|
|
779
|
+
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
|
|
780
|
+
authorizeUrl.searchParams.set('scope', scopes);
|
|
781
|
+
authorizeUrl.searchParams.set('state', state);
|
|
782
|
+
authorizeUrl.searchParams.set('code_challenge', challenge);
|
|
783
|
+
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
|
|
784
|
+
const authorizeUrlText = authorizeUrl.toString();
|
|
785
|
+
this.writeStdout(`linear oauth authorize url: ${authorizeUrlText}\n`);
|
|
786
|
+
if (!noBrowser) {
|
|
787
|
+
this.tryOpenBrowserUrl(authorizeUrlText);
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
const onListenError = (error: Error): void => {
|
|
791
|
+
const typedError = error as NodeJS.ErrnoException;
|
|
792
|
+
if (!attemptedDynamicFallback && typedError.code === 'EADDRINUSE') {
|
|
793
|
+
attemptedDynamicFallback = true;
|
|
794
|
+
this.writeStdout(
|
|
795
|
+
`linear oauth callback port ${String(preferredCallbackPort)} in use, retrying with dynamic port\n`,
|
|
796
|
+
);
|
|
797
|
+
server.listen(0, '127.0.0.1', onListening);
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
finish(typedError);
|
|
801
|
+
};
|
|
802
|
+
server.on('error', onListenError);
|
|
803
|
+
const timeoutHandle = setTimeout(() => {
|
|
804
|
+
finish(new Error('timed out waiting for linear oauth callback'));
|
|
805
|
+
}, timeoutMs);
|
|
806
|
+
timeoutHandle.unref();
|
|
807
|
+
let redirectUri = '';
|
|
808
|
+
server.listen(preferredCallbackPort, '127.0.0.1', onListening);
|
|
809
|
+
},
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
private async loginLinearWithOAuthPkce(command: ParsedAuthLoginCommand): Promise<void> {
|
|
814
|
+
const clientId = this.readRequiredEnvValue('HARNESS_LINEAR_OAUTH_CLIENT_ID');
|
|
815
|
+
const clientSecret =
|
|
816
|
+
typeof this.env.HARNESS_LINEAR_OAUTH_CLIENT_SECRET === 'string' &&
|
|
817
|
+
this.env.HARNESS_LINEAR_OAUTH_CLIENT_SECRET.trim().length > 0
|
|
818
|
+
? this.env.HARNESS_LINEAR_OAUTH_CLIENT_SECRET.trim()
|
|
819
|
+
: null;
|
|
820
|
+
const scopes = this.normalizeScopeList(
|
|
821
|
+
command.scopes ?? this.env.HARNESS_LINEAR_OAUTH_SCOPES,
|
|
822
|
+
DEFAULT_LINEAR_OAUTH_SCOPE,
|
|
823
|
+
);
|
|
824
|
+
const callbackPort = this.resolveLinearOauthCallbackPort(command);
|
|
825
|
+
const callback = await this.waitForLinearOauthCodeViaCallback(
|
|
826
|
+
clientId,
|
|
827
|
+
scopes,
|
|
828
|
+
command.timeoutMs,
|
|
829
|
+
command.noBrowser,
|
|
830
|
+
callbackPort,
|
|
831
|
+
);
|
|
832
|
+
const tokenPayload = await this.fetchJsonRecord(this.resolveLinearTokenUrl(), {
|
|
833
|
+
method: 'POST',
|
|
834
|
+
timeoutMs: command.timeoutMs,
|
|
835
|
+
headers: {
|
|
836
|
+
Accept: 'application/json',
|
|
837
|
+
'Content-Type': 'application/json',
|
|
838
|
+
},
|
|
839
|
+
body: JSON.stringify({
|
|
840
|
+
grant_type: 'authorization_code',
|
|
841
|
+
client_id: clientId,
|
|
842
|
+
...(clientSecret === null ? {} : { client_secret: clientSecret }),
|
|
843
|
+
code: callback.code,
|
|
844
|
+
redirect_uri: callback.redirectUri,
|
|
845
|
+
code_verifier: callback.verifier,
|
|
846
|
+
}),
|
|
847
|
+
});
|
|
848
|
+
const accessToken = this.asStringField(tokenPayload, 'access_token');
|
|
849
|
+
if (accessToken === null) {
|
|
850
|
+
throw new Error('linear oauth token response missing access_token');
|
|
851
|
+
}
|
|
852
|
+
const refreshToken = this.asStringField(tokenPayload, 'refresh_token');
|
|
853
|
+
const expiresAt = this.expiresAtFromExpiresIn(tokenPayload['expires_in']);
|
|
854
|
+
this.upsertHarnessSecretValue('HARNESS_LINEAR_OAUTH_CLIENT_ID', clientId);
|
|
855
|
+
this.upsertHarnessSecretValue(LINEAR_OAUTH_ACCESS_TOKEN_KEY, accessToken);
|
|
856
|
+
if (refreshToken !== null) {
|
|
857
|
+
this.upsertHarnessSecretValue(LINEAR_OAUTH_REFRESH_TOKEN_KEY, refreshToken);
|
|
858
|
+
} else {
|
|
859
|
+
this.removeHarnessSecrets([LINEAR_OAUTH_REFRESH_TOKEN_KEY]);
|
|
860
|
+
}
|
|
861
|
+
if (expiresAt !== null) {
|
|
862
|
+
this.upsertHarnessSecretValue(LINEAR_OAUTH_EXPIRES_AT_KEY, expiresAt);
|
|
863
|
+
} else {
|
|
864
|
+
this.removeHarnessSecrets([LINEAR_OAUTH_EXPIRES_AT_KEY]);
|
|
865
|
+
}
|
|
866
|
+
this.writeStdout(
|
|
867
|
+
`linear oauth login complete: token saved to ${LINEAR_OAUTH_ACCESS_TOKEN_KEY}\n`,
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
private async refreshLinearOauthToken(
|
|
872
|
+
options: RefreshLinearOauthTokenOptions,
|
|
873
|
+
): Promise<RefreshLinearOauthTokenResult> {
|
|
874
|
+
const refreshToken = this.env[LINEAR_OAUTH_REFRESH_TOKEN_KEY];
|
|
875
|
+
if (typeof refreshToken !== 'string' || refreshToken.trim().length === 0) {
|
|
876
|
+
return {
|
|
877
|
+
refreshed: false,
|
|
878
|
+
skippedReason: 'missing linear oauth refresh token',
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
const expiresAt = this.parseOptionalIsoTimestamp(this.env[LINEAR_OAUTH_EXPIRES_AT_KEY]);
|
|
882
|
+
if (!options.force && !this.isTokenNearExpiry(expiresAt)) {
|
|
883
|
+
return {
|
|
884
|
+
refreshed: false,
|
|
885
|
+
skippedReason: 'linear oauth token still valid',
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
const clientId = this.readRequiredEnvValue('HARNESS_LINEAR_OAUTH_CLIENT_ID');
|
|
889
|
+
const clientSecret =
|
|
890
|
+
typeof this.env.HARNESS_LINEAR_OAUTH_CLIENT_SECRET === 'string' &&
|
|
891
|
+
this.env.HARNESS_LINEAR_OAUTH_CLIENT_SECRET.trim().length > 0
|
|
892
|
+
? this.env.HARNESS_LINEAR_OAUTH_CLIENT_SECRET.trim()
|
|
893
|
+
: null;
|
|
894
|
+
const tokenPayload = await this.fetchJsonRecord(this.resolveLinearTokenUrl(), {
|
|
895
|
+
method: 'POST',
|
|
896
|
+
timeoutMs: options.timeoutMs,
|
|
897
|
+
headers: {
|
|
898
|
+
Accept: 'application/json',
|
|
899
|
+
'Content-Type': 'application/json',
|
|
900
|
+
},
|
|
901
|
+
body: JSON.stringify({
|
|
902
|
+
grant_type: 'refresh_token',
|
|
903
|
+
client_id: clientId,
|
|
904
|
+
...(clientSecret === null ? {} : { client_secret: clientSecret }),
|
|
905
|
+
refresh_token: refreshToken.trim(),
|
|
906
|
+
}),
|
|
907
|
+
});
|
|
908
|
+
const accessToken = this.asStringField(tokenPayload, 'access_token');
|
|
909
|
+
if (accessToken === null) {
|
|
910
|
+
throw new Error('linear oauth refresh response missing access_token');
|
|
911
|
+
}
|
|
912
|
+
const nextRefreshToken =
|
|
913
|
+
this.asStringField(tokenPayload, 'refresh_token') ?? refreshToken.trim();
|
|
914
|
+
const nextExpiresAt = this.expiresAtFromExpiresIn(tokenPayload['expires_in']);
|
|
915
|
+
this.upsertHarnessSecretValue(LINEAR_OAUTH_ACCESS_TOKEN_KEY, accessToken);
|
|
916
|
+
this.upsertHarnessSecretValue(LINEAR_OAUTH_REFRESH_TOKEN_KEY, nextRefreshToken);
|
|
917
|
+
if (nextExpiresAt !== null) {
|
|
918
|
+
this.upsertHarnessSecretValue(LINEAR_OAUTH_EXPIRES_AT_KEY, nextExpiresAt);
|
|
919
|
+
}
|
|
920
|
+
return {
|
|
921
|
+
refreshed: true,
|
|
922
|
+
skippedReason: null,
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
private formatAuthProviderStatusLine(
|
|
927
|
+
provider: AuthProvider,
|
|
928
|
+
manualTokenEnvVar: string,
|
|
929
|
+
oauthAccessTokenEnvVar: string,
|
|
930
|
+
): string {
|
|
931
|
+
const manualToken = this.env[manualTokenEnvVar];
|
|
932
|
+
const oauthAccessToken = this.env[oauthAccessTokenEnvVar];
|
|
933
|
+
const manualPresent = typeof manualToken === 'string' && manualToken.trim().length > 0;
|
|
934
|
+
const oauthPresent = typeof oauthAccessToken === 'string' && oauthAccessToken.trim().length > 0;
|
|
935
|
+
const activeSource = manualPresent ? 'manual' : oauthPresent ? 'oauth' : 'none';
|
|
936
|
+
const refreshKey =
|
|
937
|
+
provider === 'github' ? GITHUB_OAUTH_REFRESH_TOKEN_KEY : LINEAR_OAUTH_REFRESH_TOKEN_KEY;
|
|
938
|
+
const expiresKey =
|
|
939
|
+
provider === 'github' ? GITHUB_OAUTH_EXPIRES_AT_KEY : LINEAR_OAUTH_EXPIRES_AT_KEY;
|
|
940
|
+
const refreshPresent =
|
|
941
|
+
typeof this.env[refreshKey] === 'string' &&
|
|
942
|
+
(this.env[refreshKey] as string).trim().length > 0;
|
|
943
|
+
const expiresAt = this.env[expiresKey];
|
|
944
|
+
const expiresText =
|
|
945
|
+
typeof expiresAt === 'string' && expiresAt.trim().length > 0 ? expiresAt.trim() : 'n/a';
|
|
946
|
+
return `${provider}: ${activeSource === 'none' ? 'disconnected' : 'connected'} active=${activeSource} manualEnvVar=${manualTokenEnvVar} oauthEnvVar=${oauthAccessTokenEnvVar} refresh=${refreshPresent ? 'yes' : 'no'} expiresAt=${expiresText}`;
|
|
947
|
+
}
|
|
948
|
+
}
|