@andypai/agent-kanban 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -168,7 +168,7 @@ export interface JiraIssueType {
168
168
  name: string
169
169
  }
170
170
 
171
- export interface JiraChangelogItem {
171
+ interface JiraChangelogItem {
172
172
  field: string
173
173
  fieldtype?: string
174
174
  fromString?: string | null
@@ -177,7 +177,7 @@ export interface JiraChangelogItem {
177
177
  to?: string | null
178
178
  }
179
179
 
180
- export interface JiraChangelogEntry {
180
+ interface JiraChangelogEntry {
181
181
  id: string
182
182
  author?: { accountId?: string; displayName?: string }
183
183
  created: string
@@ -47,12 +47,13 @@ import type {
47
47
  UpdateTaskInput,
48
48
  } from './types'
49
49
  import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
50
+ import { WEBHOOK_SECRET_ENV, webhookSecretFromEnv } from '../tracker-config'
50
51
  import { warnOnce } from './warn-once'
51
52
  import { applyTaskFilters, forEachWithConcurrency, SyncGate, syncStatusFromMeta } from './sync-core'
52
53
 
53
- export const FULL_RECONCILE_INTERVAL_MS = 5 * 60_000
54
+ const FULL_RECONCILE_INTERVAL_MS = 5 * 60_000
54
55
 
55
- export function shouldRunFullReconcile(lastFullSyncAt: string | null, now: number): boolean {
56
+ function shouldRunFullReconcile(lastFullSyncAt: string | null, now: number): boolean {
56
57
  if (!lastFullSyncAt) return true
57
58
  const lastFullSyncAtMs = Date.parse(lastFullSyncAt)
58
59
  if (!Number.isFinite(lastFullSyncAtMs)) return true
@@ -63,7 +64,7 @@ export function shouldRunFullReconcile(lastFullSyncAt: string | null, now: numbe
63
64
  // priorities; the write path looks up the resolved name (case-insensitive)
64
65
  // in the cached `jira_priorities` table, so renames that preserve the default
65
66
  // casing still resolve.
66
- export const CANONICAL_TO_JIRA_DEFAULT: Record<Priority, string> = {
67
+ const CANONICAL_TO_JIRA_DEFAULT: Record<Priority, string> = {
67
68
  urgent: 'Highest',
68
69
  high: 'High',
69
70
  medium: 'Medium',
@@ -872,14 +873,19 @@ export class JiraProviderCore implements KanbanProvider {
872
873
  // Shared webhook dispatch. Postgres wraps this with webhook-event auditing;
873
874
  // SQLite calls it directly via the default handleWebhook above.
874
875
  protected async handleWebhookCore(payload: WebhookRequest): Promise<WebhookResult> {
875
- if (!process.env['JIRA_WEBHOOK_SECRET']) {
876
+ // Resolve the signing secret through WEBHOOK_SECRET_ENV so the runtime
877
+ // enforcement here and the assertTunnelSecurity tunnel gate read the same env
878
+ // name — they can't drift into a fail-open where the gate requires one env and
879
+ // verification reads another (unset) one.
880
+ const secret = webhookSecretFromEnv('jira')
881
+ if (!secret) {
876
882
  warnOnce(
877
883
  'jira-webhook-open-dev-mode',
878
- '[jira] JIRA_WEBHOOK_SECRET is not set — accepting webhook without signature verification (open dev mode)',
884
+ `[jira] ${WEBHOOK_SECRET_ENV['jira']} is not set — accepting webhook without signature verification (open dev mode)`,
879
885
  )
880
886
  }
881
887
  const auth = authorizeWebhook({
882
- secret: process.env['JIRA_WEBHOOK_SECRET'],
888
+ secret,
883
889
  rawBody: payload.rawBody,
884
890
  signature: headerLower(payload.headers, 'x-hub-signature'),
885
891
  verify: verifySha256HmacSignatureHeader,
@@ -316,10 +316,10 @@ export function upsertProjects(
316
316
  }
317
317
 
318
318
  // Bound per-value storage so repeated edits to a long description can't balloon the cache.
319
- // Exported and shared with the Postgres cache (postgres-linear-cache.ts) so both
320
- // backends clamp identically and the truncation marker can't drift between them.
321
- export const ACTIVITY_VALUE_MAX_CHARS = 4096
322
- export const ACTIVITY_TRUNCATION_SUFFIX = '…[truncated]'
319
+ // clampActivityValue is shared with the Postgres cache (postgres-linear-cache.ts)
320
+ // so both backends clamp identically and the truncation marker can't drift.
321
+ const ACTIVITY_VALUE_MAX_CHARS = 4096
322
+ const ACTIVITY_TRUNCATION_SUFFIX = '…[truncated]'
323
323
  const ACTIVITY_VALUE_BUDGET = ACTIVITY_VALUE_MAX_CHARS - ACTIVITY_TRUNCATION_SUFFIX.length
324
324
 
325
325
  export function clampActivityValue(value: string): string {
@@ -694,7 +694,7 @@ export async function resolveLabelIdsForCreate(
694
694
  return resolveLinearLabelIds(inputLabels, await client.listIssueLabels())
695
695
  }
696
696
 
697
- export function resolveLinearLabelIds(
697
+ function resolveLinearLabelIds(
698
698
  inputLabels: string[] | undefined,
699
699
  availableLabels: LinearIssueLabel[],
700
700
  ): string[] | undefined {
@@ -40,6 +40,7 @@ import type {
40
40
  UpdateTaskInput,
41
41
  } from './types'
42
42
  import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
43
+ import { WEBHOOK_SECRET_ENV, webhookSecretFromEnv } from '../tracker-config'
43
44
  import { warnOnce } from './warn-once'
44
45
  import {
45
46
  applyTaskFilters,
@@ -596,14 +597,19 @@ export class LinearProviderCore implements KanbanProvider {
596
597
  // Shared webhook dispatch. Postgres wraps this with webhook-event auditing;
597
598
  // SQLite calls it directly via the default handleWebhook above.
598
599
  protected async handleWebhookCore(payload: WebhookRequest): Promise<WebhookResult> {
599
- if (!process.env['LINEAR_WEBHOOK_SECRET']) {
600
+ // Resolve the signing secret through WEBHOOK_SECRET_ENV so the runtime
601
+ // enforcement here and the assertTunnelSecurity tunnel gate read the same env
602
+ // name — they can't drift into a fail-open where the gate requires one env and
603
+ // verification reads another (unset) one.
604
+ const secret = webhookSecretFromEnv('linear')
605
+ if (!secret) {
600
606
  warnOnce(
601
607
  'linear-webhook-open-dev-mode',
602
- '[linear] LINEAR_WEBHOOK_SECRET is not set — accepting webhook without signature verification (open dev mode)',
608
+ `[linear] ${WEBHOOK_SECRET_ENV['linear']} is not set — accepting webhook without signature verification (open dev mode)`,
603
609
  )
604
610
  }
605
611
  const auth = authorizeWebhook({
606
- secret: process.env['LINEAR_WEBHOOK_SECRET'],
612
+ secret,
607
613
  rawBody: payload.rawBody,
608
614
  signature: headerLower(payload.headers, 'linear-signature'),
609
615
  verify: verifyHmacSha256,
@@ -5,7 +5,7 @@ import { LocalProviderCore } from './local-core'
5
5
  import { SqliteLocalStore } from './sqlite-local-store'
6
6
 
7
7
  export class LocalProvider extends LocalProviderCore {
8
- constructor(db: Database, dbPath = getDbPath()) {
9
- super(new SqliteLocalStore(db, dbPath))
8
+ constructor(db: Database, dbPath = getDbPath(), defaultTaskColumn?: string) {
9
+ super(new SqliteLocalStore(db, dbPath, defaultTaskColumn))
10
10
  }
11
11
  }
@@ -15,7 +15,7 @@ import { parseProviderTeamInfo } from './team-info'
15
15
 
16
16
  export type { LinearActivityRow, LinearStateRow, LinearSyncMeta } from './linear-cache'
17
17
 
18
- export type LinearIssueRow = LinearTaskRow
18
+ type LinearIssueRow = LinearTaskRow
19
19
 
20
20
  /**
21
21
  * Postgres-backed cache/repository for the Linear provider. Mirrors the role of
@@ -30,6 +30,7 @@ export class SqliteLocalStore implements LocalStorePort {
30
30
  constructor(
31
31
  private readonly db: Database,
32
32
  private readonly dbPath: string,
33
+ private readonly defaultTaskColumn?: string,
33
34
  ) {}
34
35
 
35
36
  getBoard() {
@@ -53,7 +54,10 @@ export class SqliteLocalStore implements LocalStorePort {
53
54
  }
54
55
 
55
56
  createTask(input: CreateTaskInput) {
56
- return addTask(this.db, input.title, input)
57
+ return addTask(this.db, input.title, {
58
+ ...input,
59
+ column: input.column ?? this.defaultTaskColumn,
60
+ })
57
61
  }
58
62
 
59
63
  updateTask(idOrRef: string, input: Omit<UpdateTaskInput, 'expectedVersion'>) {
@@ -1,13 +1,11 @@
1
1
  import { getDbPath } from './db'
2
2
 
3
- export type KanbanStorageMode = 'sqlite' | 'postgres'
4
-
5
- export interface SqliteKanbanStorageConfig {
3
+ interface SqliteKanbanStorageConfig {
6
4
  mode: 'sqlite'
7
5
  sqlitePath: string
8
6
  }
9
7
 
10
- export interface PostgresKanbanStorageConfig {
8
+ interface PostgresKanbanStorageConfig {
11
9
  mode: 'postgres'
12
10
  databaseUrl: string
13
11
  }
@@ -1,4 +1,5 @@
1
1
  import { ErrorCode, KanbanError } from './errors'
2
+ import { parseDecimalDigits } from './transport-input'
2
3
 
3
4
  export const DEFAULT_POLLING_SYNC_INTERVAL_MS = 30_000
4
5
  export const MIN_POLLING_SYNC_INTERVAL_MS = 1_000
@@ -10,8 +11,12 @@ export function resolvePollingSyncIntervalMs(
10
11
  const value = raw?.trim()
11
12
  if (!value) return DEFAULT_POLLING_SYNC_INTERVAL_MS
12
13
 
13
- const parsed = Number(value)
14
- if (!Number.isInteger(parsed) || parsed < MIN_POLLING_SYNC_INTERVAL_MS) {
14
+ // Digits-only via the shared parser (rejects hex/scientific like `0x3e8`/`1e3`
15
+ // and over-long strings that round past MAX_SAFE_INTEGER / overflow to
16
+ // Infinity); a set-but-invalid value is a config error, not a fall-back to the
17
+ // default, mirroring the strict --sync-interval-ms CLI flag.
18
+ const parsed = parseDecimalDigits(value)
19
+ if (parsed === null || parsed < MIN_POLLING_SYNC_INTERVAL_MS) {
15
20
  throw new KanbanError(
16
21
  ErrorCode.INVALID_CONFIG,
17
22
  `${opts.label ?? 'KANBAN_SYNC_INTERVAL_MS'} must be an integer >= ${MIN_POLLING_SYNC_INTERVAL_MS}`,
@@ -1,9 +1,60 @@
1
1
  import { ErrorCode, KanbanError } from './errors'
2
2
  import { providerNotConfigured } from './providers/errors'
3
3
  import { resolvePollingSyncIntervalMs } from './sync-config'
4
+ import { parseDecimalDigits } from './transport-input'
4
5
 
5
6
  export type TrackerProvider = 'local' | 'linear' | 'jira'
6
7
 
8
+ /**
9
+ * Single source of truth for which env var holds each provider's webhook signing
10
+ * secret (`null` = the provider has no webhook ingestion). Typed as
11
+ * `Record<TrackerProvider, …>`, so adding a new provider to the union is a
12
+ * compile error here until its secret env is declared — preventing a new
13
+ * webhook-capable provider from silently slipping past the tunnel-security gate
14
+ * (assertTunnelSecurity) with no secret enforced.
15
+ */
16
+ export const WEBHOOK_SECRET_ENV: Record<TrackerProvider, string | null> = {
17
+ local: null,
18
+ linear: 'LINEAR_WEBHOOK_SECRET',
19
+ jira: 'JIRA_WEBHOOK_SECRET',
20
+ }
21
+
22
+ /**
23
+ * Normalize the raw `KANBAN_PROVIDER` env value to a known `TrackerProvider`,
24
+ * falling back to `'local'` for anything unset/unrecognized — matching how
25
+ * trackerConfigFromEnv resolves the provider. Centralized so the security gate
26
+ * and the config loader agree on a single derivation.
27
+ */
28
+ export function trackerProviderFromEnv(
29
+ env: Record<string, string | undefined> = process.env,
30
+ ): TrackerProvider {
31
+ const raw = (env['KANBAN_PROVIDER'] ?? 'local').trim().toLowerCase()
32
+ // Recognize exactly the providers declared in WEBHOOK_SECRET_ENV — own keys
33
+ // only, so inherited names like "constructor"/"toString" don't match — and
34
+ // fall back to local otherwise. Deriving from the map keeps this normalizer in
35
+ // lockstep with it, so a newly-added provider is picked up here automatically.
36
+ return Object.prototype.hasOwnProperty.call(WEBHOOK_SECRET_ENV, raw)
37
+ ? (raw as TrackerProvider)
38
+ : 'local'
39
+ }
40
+
41
+ /**
42
+ * Resolve a provider's webhook signing secret from `env` via WEBHOOK_SECRET_ENV
43
+ * (the single source of truth), returning `undefined` when the provider declares
44
+ * no secret env or the env var is unset — the case where the webhook handler
45
+ * falls back to open dev mode. The runtime signature enforcement (the provider
46
+ * webhook handlers) and the assertTunnelSecurity tunnel gate both resolve the
47
+ * secret env name through this same map, so the gate cannot require one env name
48
+ * while enforcement reads another (a fail-open if they drifted).
49
+ */
50
+ export function webhookSecretFromEnv(
51
+ provider: TrackerProvider,
52
+ env: Record<string, string | undefined> = process.env,
53
+ ): string | undefined {
54
+ const envName = WEBHOOK_SECRET_ENV[provider]
55
+ return envName ? env[envName] : undefined
56
+ }
57
+
7
58
  export interface LocalTrackerConfig {
8
59
  provider: 'local'
9
60
  defaultColumns?: string[]
@@ -11,14 +62,14 @@ export interface LocalTrackerConfig {
11
62
  syncIntervalMs?: number
12
63
  }
13
64
 
14
- export interface LinearTrackerConfig {
65
+ interface LinearTrackerConfig {
15
66
  provider: 'linear'
16
67
  apiKey: string
17
68
  teamId: string
18
69
  syncIntervalMs?: number
19
70
  }
20
71
 
21
- export interface JiraTrackerConfig {
72
+ interface JiraTrackerConfig {
22
73
  provider: 'jira'
23
74
  baseUrl: string
24
75
  email: string
@@ -34,7 +85,7 @@ export type TrackerConfig = LocalTrackerConfig | LinearTrackerConfig | JiraTrack
34
85
  export function trackerConfigFromEnv(
35
86
  env: Record<string, string | undefined> = process.env,
36
87
  ): TrackerConfig {
37
- const provider = (env['KANBAN_PROVIDER'] ?? 'local').trim().toLowerCase()
88
+ const provider = trackerProviderFromEnv(env)
38
89
 
39
90
  if (provider === 'linear') {
40
91
  const apiKey = env['LINEAR_API_KEY']
@@ -70,15 +121,14 @@ export function trackerConfigFromEnv(
70
121
  `${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} required when KANBAN_PROVIDER=jira`,
71
122
  )
72
123
  }
73
- const boardIdRaw = env['JIRA_BOARD_ID']
74
- const boardId = boardIdRaw ? Number.parseInt(boardIdRaw, 10) : undefined
124
+ const boardId = jiraBoardIdFromEnv(env['JIRA_BOARD_ID'])
75
125
  return {
76
126
  provider,
77
127
  baseUrl: baseUrl!,
78
128
  email: email!,
79
129
  apiToken: apiToken!,
80
130
  projectKey: projectKey!,
81
- ...(Number.isFinite(boardId) ? { boardId } : {}),
131
+ ...(boardId !== undefined ? { boardId } : {}),
82
132
  defaultIssueType: env['JIRA_ISSUE_TYPE'] ?? 'Task',
83
133
  syncIntervalMs: resolvePollingSyncIntervalMs(env['KANBAN_SYNC_INTERVAL_MS']),
84
134
  }
@@ -94,6 +144,24 @@ export function trackerConfigFromEnv(
94
144
  }
95
145
  }
96
146
 
147
+ /**
148
+ * Resolve the optional `JIRA_BOARD_ID` env var to a positive integer board id.
149
+ * Unset/blank → `undefined` (no board pinned). A set-but-invalid value throws
150
+ * `INVALID_CONFIG` rather than silently falling back: the previous
151
+ * `Number.parseInt` accepted trailing garbage and signs (`'12abc'→12`,
152
+ * `'-5'→-5`, `'1e3'→1`), quietly pinning a *wrong* board. This matches how
153
+ * resolvePollingSyncIntervalMs treats a malformed optional numeric env var.
154
+ */
155
+ function jiraBoardIdFromEnv(raw: string | undefined): number | undefined {
156
+ const value = raw?.trim()
157
+ if (!value) return undefined
158
+ const parsed = parseDecimalDigits(value)
159
+ if (parsed === null || parsed < 1) {
160
+ throw new KanbanError(ErrorCode.INVALID_CONFIG, 'JIRA_BOARD_ID must be a positive integer')
161
+ }
162
+ return parsed
163
+ }
164
+
97
165
  function defaultColumnsFromEnv(env: Record<string, string | undefined>): string[] | undefined {
98
166
  const raw = env['KANBAN_DEFAULT_COLUMNS']?.trim()
99
167
  if (!raw) return undefined
@@ -1,26 +1,58 @@
1
1
  import { ErrorCode, KanbanError } from './errors'
2
2
 
3
3
  /**
4
- * Parse an optional positive integer supplied through a transport boundary
5
- * (HTTP query string, CLI flag). Returns `undefined` when the value is absent,
6
- * and throws a `KanbanError(INVALID_ARGUMENT)` for anything that is not a
7
- * positive integer `NaN`, negatives, zero, decimals, and trailing-garbage
8
- * strings like `"5abc"`. Centralizing this keeps invalid limits from leaking
9
- * into provider logic, where they would otherwise produce inconsistent
10
- * behavior across SQL `LIMIT`, `Array.prototype.slice`, and Postgres.
4
+ * Parse a plain decimal-digit string into an exact non-negative integer, or
5
+ * `null` if it is not one. Rejects signs, decimals, and exponent/hex forms like
6
+ * `"-1"` / `"3.5"` / `"1e100"` / `"0x10"` that `Number()` would otherwise coerce
7
+ * to an integer, and rejects an over-long digit string that `Number()` rounds
8
+ * past `MAX_SAFE_INTEGER` or overflows to `Infinity`, so the result is always
9
+ * exact. Surrounding whitespace is trimmed. This is the shared digits-only
10
+ * primitive the throwing boundary/config parsers (`parseBoundedInt`,
11
+ * `resolvePollingSyncIntervalMs`, the JIRA_BOARD_ID loader) build on — keeping a
12
+ * single definition of "what counts as an integer" so they can't drift.
13
+ */
14
+ export function parseDecimalDigits(value: string): number | null {
15
+ const trimmed = value.trim()
16
+ if (!/^\d+$/.test(trimmed)) return null
17
+ const parsed = Number(trimmed)
18
+ return Number.isSafeInteger(parsed) ? parsed : null
19
+ }
20
+
21
+ /**
22
+ * Parse a required integer supplied through a transport boundary (HTTP query
23
+ * string, CLI flag) into the inclusive range `[min, max]` (max defaults to
24
+ * `MAX_SAFE_INTEGER`), via {@link parseDecimalDigits}. Throws
25
+ * `KanbanError(INVALID_ARGUMENT)` on any violation.
26
+ */
27
+ export function parseBoundedInt(
28
+ value: string,
29
+ opts: { min: number; max?: number; field: string },
30
+ ): number {
31
+ const parsed = parseDecimalDigits(value)
32
+ const max = opts.max ?? Number.MAX_SAFE_INTEGER
33
+ if (parsed === null || parsed < opts.min || parsed > max) {
34
+ const range = opts.max === undefined ? `>= ${opts.min}` : `between ${opts.min} and ${opts.max}`
35
+ throw new KanbanError(ErrorCode.INVALID_ARGUMENT, `${opts.field} must be an integer ${range}`)
36
+ }
37
+ return parsed
38
+ }
39
+
40
+ /**
41
+ * Parse an optional positive integer supplied through a transport boundary.
42
+ * Returns `undefined` when the value is absent/blank, otherwise resolves via
43
+ * {@link parseDecimalDigits} (which trims and caps at `MAX_SAFE_INTEGER`) and
44
+ * enforces `>= 1`, throwing `KanbanError(INVALID_ARGUMENT)` framed as "positive
45
+ * integer" so invalid limits don't leak into provider logic (SQL `LIMIT`,
46
+ * `slice`, PG).
11
47
  */
12
48
  export function parsePositiveInt(
13
49
  value: string | null | undefined,
14
50
  field = 'limit',
15
51
  ): number | undefined {
16
52
  if (value === null || value === undefined) return undefined
17
- const trimmed = value.trim()
18
- if (trimmed === '') return undefined
19
- // Plain decimal digits only reject signs, decimals, and exponent forms like
20
- // "1e100" that Number() would otherwise treat as integers and let escape into
21
- // SQL `LIMIT`. Cap at MAX_SAFE_INTEGER so the parsed value stays exact.
22
- const parsed = /^\d+$/.test(trimmed) ? Number(trimmed) : NaN
23
- if (!Number.isInteger(parsed) || parsed <= 0 || parsed > Number.MAX_SAFE_INTEGER) {
53
+ if (value.trim() === '') return undefined
54
+ const parsed = parseDecimalDigits(value)
55
+ if (parsed === null || parsed < 1) {
24
56
  throw new KanbanError(
25
57
  ErrorCode.INVALID_ARGUMENT,
26
58
  `${field} must be a positive integer (received '${value}')`,
package/src/tunnel.ts CHANGED
@@ -57,15 +57,32 @@ export function startCloudflareTunnel(port: number, opts: TunnelOptions = {}): T
57
57
  ): Promise<void> => {
58
58
  if (!stream) return
59
59
  const decoder = new TextDecoder()
60
+ // Accumulate across chunks: cloudflared may split the URL across read
61
+ // boundaries, so matching each chunk in isolation can miss it.
62
+ let buffer = ''
60
63
  for await (const chunk of stream) {
61
- const text = decoder.decode(chunk as Uint8Array, { stream: true })
62
- const match = text.match(TRYCLOUDFLARE_URL)
63
- if (match) announce(match[0])
64
+ // Once announced, keep draining the pipe (so the child doesn't block on a
65
+ // full stdout buffer) but stop scanning.
66
+ if (announced) continue
67
+ buffer += decoder.decode(chunk as Uint8Array, { stream: true })
68
+ const match = buffer.match(TRYCLOUDFLARE_URL)
69
+ if (match) {
70
+ announce(match[0])
71
+ } else if (buffer.length > 4096) {
72
+ // Bound memory while keeping a tail long enough to span a split URL.
73
+ buffer = buffer.slice(-256)
74
+ }
64
75
  }
65
76
  }
66
77
 
67
- void scanForUrl(child.stdout as ReadableStream<Uint8Array>)
68
- void scanForUrl(child.stderr as ReadableStream<Uint8Array>)
78
+ // A stream error while draining stdout/stderr (e.g. the pipe tears down as the
79
+ // child is killed) must not surface as an unhandled rejection; the child.exited
80
+ // handler below still emits the no-URL warning when nothing was announced.
81
+ const drain = (stream: ReadableStream<Uint8Array> | null | undefined): void => {
82
+ void scanForUrl(stream).catch(() => {})
83
+ }
84
+ drain(child.stdout as ReadableStream<Uint8Array>)
85
+ drain(child.stderr as ReadableStream<Uint8Array>)
69
86
 
70
87
  void child.exited.then((code) => {
71
88
  if (!announced) {