codex-notify 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +50 -0
  3. data/LICENSE +21 -0
  4. data/README.md +782 -0
  5. data/bin/codex-notify +6 -0
  6. data/bin/codex-notify-hook +6 -0
  7. data/lib/codex_notify/cli.rb +112 -0
  8. data/lib/codex_notify/config.rb +113 -0
  9. data/lib/codex_notify/config_diagnostics.rb +61 -0
  10. data/lib/codex_notify/config_migrator.rb +148 -0
  11. data/lib/codex_notify/config_support.rb +157 -0
  12. data/lib/codex_notify/destination_name.rb +18 -0
  13. data/lib/codex_notify/destination_resolver.rb +68 -0
  14. data/lib/codex_notify/durable_slack_publisher.rb +70 -0
  15. data/lib/codex_notify/env_source_loader.rb +91 -0
  16. data/lib/codex_notify/hook_cli.rb +90 -0
  17. data/lib/codex_notify/hook_config.rb +110 -0
  18. data/lib/codex_notify/hook_event.rb +16 -0
  19. data/lib/codex_notify/hook_formatter.rb +61 -0
  20. data/lib/codex_notify/hook_input_validator.rb +241 -0
  21. data/lib/codex_notify/hook_runner.rb +181 -0
  22. data/lib/codex_notify/hook_store.rb +133 -0
  23. data/lib/codex_notify/hook_thread_publisher.rb +107 -0
  24. data/lib/codex_notify/log_event_parser.rb +230 -0
  25. data/lib/codex_notify/message_formatter.rb +115 -0
  26. data/lib/codex_notify/outbox_commands.rb +43 -0
  27. data/lib/codex_notify/secret_protection.rb +37 -0
  28. data/lib/codex_notify/session_log.rb +63 -0
  29. data/lib/codex_notify/slack_client.rb +117 -0
  30. data/lib/codex_notify/slack_delivery_worker.rb +269 -0
  31. data/lib/codex_notify/slack_outbox.rb +206 -0
  32. data/lib/codex_notify/stream_processor.rb +141 -0
  33. data/lib/codex_notify/trusted_config_loader.rb +159 -0
  34. data/lib/codex_notify/version.rb +5 -0
  35. data/lib/codex_notify.rb +36 -0
  36. metadata +96 -0
data/bin/codex-notify ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'codex_notify'
5
+
6
+ exit(CodexNotify.main(ARGV))
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'codex_notify/hook_cli'
5
+
6
+ exit(CodexNotify::HookCLI.main(ARGV))
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'pathname'
5
+ require 'time'
6
+ require_relative 'config'
7
+ require_relative 'log_event_parser'
8
+ require_relative 'message_formatter'
9
+ require_relative 'session_log'
10
+ require_relative 'slack_client'
11
+ require_relative 'stream_processor'
12
+ require_relative 'hook_store'
13
+ require_relative 'slack_outbox'
14
+ require_relative 'durable_slack_publisher'
15
+ require_relative 'outbox_commands'
16
+
17
+ module CodexNotify
18
+ module CLI
19
+ PostClient = Data.define(:token, :channel, :post_callable) do
20
+ def post(text, thread_ts: nil)
21
+ post_callable.call(token, channel, text, thread_ts)
22
+ end
23
+ end
24
+
25
+ module_function
26
+
27
+ def slack_post(token, channel, text, thread_ts = nil)
28
+ SlackClient.new(token:, channel:).post(text, thread_ts:)
29
+ end
30
+
31
+ def main(argv = nil, stdin: nil, stderr: $stderr, stdout: $stdout, legacy_checkout_root: nil)
32
+ args = CodexNotify::Config.parse_args(argv, stderr:, legacy_checkout_root:)
33
+ if args.migrate_config
34
+ env_path = args.env_file if args.env_file_explicit
35
+ return ConfigMigrator.new(legacy_checkout_root:, stdout:, stderr:).run(
36
+ env_path:,
37
+ config_path: args.config_file
38
+ )
39
+ end
40
+ if args.outbox_action
41
+ return OutboxCommands.run(
42
+ action: args.outbox_action,
43
+ id: args.outbox_id,
44
+ outbox_dir: args.outbox_dir,
45
+ token: args.token,
46
+ channel: args.channel,
47
+ state_file: Pathname(args.outbox_dir).join('thread-state.json'),
48
+ stdout:,
49
+ stderr:
50
+ )
51
+ end
52
+ unless args.token && args.channel
53
+ stderr.puts('ERROR: need --token/--channel or a Slack destination from environment or config file')
54
+ return 2
55
+ end
56
+
57
+ cwd = Dir.pwd
58
+ title = args.title || "Codex run: #{File.basename(cwd)}"
59
+ session_file = args.session_file ? Pathname(args.session_file) : CodexNotify::SessionLog.find_latest_session_file(Pathname(args.sessions_dir))
60
+ unless session_file&.exist?
61
+ stderr.puts('ERROR: no Codex session log file found')
62
+ return 2
63
+ end
64
+ root_message = CodexNotify::MessageFormatter.build_root_message(
65
+ title,
66
+ cwd,
67
+ user_name: args.user_name,
68
+ session_id: CodexNotify::SessionLog.session_id_from_log(session_file) ||
69
+ CodexNotify::SessionLog.session_id_from_path(session_file)
70
+ )
71
+ outbox = SlackOutbox.new(args.outbox_dir)
72
+ store = HookStore.new(Pathname(args.outbox_dir).join('thread-state.json'))
73
+ publisher = DurableSlackPublisher.new(
74
+ client: PostClient.new(args.token, args.channel, method(:slack_post)),
75
+ store:,
76
+ outbox:,
77
+ channel: args.channel,
78
+ throttle_sec: args.throttle_sec
79
+ )
80
+
81
+ code = CodexNotify::StreamProcessor.process_codex_log_stream(
82
+ CodexNotify::SessionLog.iter_follow_lines(
83
+ session_file,
84
+ poll_sec: args.poll_sec,
85
+ once: args.once,
86
+ start_at_end: !args.once,
87
+ sleep_func: method(:sleep)
88
+ ),
89
+ token: args.token,
90
+ channel: args.channel,
91
+ root_message:,
92
+ initial_prompt: args.prompt,
93
+ user_name: args.user_name,
94
+ include_tools: args.include_tools,
95
+ throttle_sec: args.throttle_sec,
96
+ post_func: method(:slack_post),
97
+ publisher:
98
+ )
99
+ stderr.puts('ERROR: Slack delivery requires outbox review; run --outbox-status') if code == 1
100
+ code
101
+ rescue Interrupt
102
+ stderr.puts('Stopped.')
103
+ 0
104
+ rescue SlackOutbox::Error => e
105
+ stderr.puts("ERROR: #{e.message}")
106
+ 1
107
+ rescue Config::Error, ConfigMigrator::Error, OptionParser::ParseError => e
108
+ stderr.puts("ERROR: #{e.message}")
109
+ 2
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'pathname'
5
+ require_relative 'config_support'
6
+ require_relative 'config_migrator'
7
+ require_relative 'destination_resolver'
8
+ require_relative 'env_source_loader'
9
+
10
+ module CodexNotify
11
+ module Config
12
+ extend ConfigSupport
13
+
14
+ DEFAULT_ENV_PATH = ConfigSupport::DEFAULT_ENV_PATH
15
+ DEFAULT_SESSIONS_DIR = Pathname(File.expand_path('~/.codex/sessions'))
16
+ DEFAULT_OUTBOX_DIR = Pathname(File.expand_path('~/.codex-notify/outbox'))
17
+
18
+ class Error < StandardError; end
19
+
20
+ Args = Struct.new(
21
+ :env_file,
22
+ :env_file_explicit,
23
+ :config_file,
24
+ :migrate_config,
25
+ :token,
26
+ :channel,
27
+ :destination,
28
+ :user_name,
29
+ :prompt,
30
+ :title,
31
+ :include_tools,
32
+ :throttle_sec,
33
+ :sessions_dir,
34
+ :session_file,
35
+ :poll_sec,
36
+ :once,
37
+ :outbox_dir,
38
+ :outbox_action,
39
+ :outbox_id,
40
+ :token_from_cli,
41
+ keyword_init: true
42
+ )
43
+
44
+ module_function
45
+
46
+ def build_parser
47
+ options = Args.new(
48
+ env_file: DEFAULT_ENV_PATH,
49
+ env_file_explicit: false,
50
+ config_file: nil,
51
+ migrate_config: false,
52
+ token: nil,
53
+ channel: nil,
54
+ destination: nil,
55
+ user_name: nil,
56
+ prompt: nil,
57
+ title: nil,
58
+ include_tools: false,
59
+ throttle_sec: 1.05,
60
+ sessions_dir: DEFAULT_SESSIONS_DIR.to_s,
61
+ session_file: nil,
62
+ poll_sec: 1.0,
63
+ once: false,
64
+ outbox_dir: nil,
65
+ outbox_action: nil,
66
+ outbox_id: nil,
67
+ token_from_cli: false
68
+ )
69
+
70
+ parser = OptionParser.new do |opts|
71
+ opts.banner = 'Usage: codex-notify [options]'
72
+ add_common_options(opts, options)
73
+ opts.on('--prompt PROMPT') { |v| options.prompt = v }
74
+ opts.on('--include-tools') { options.include_tools = true }
75
+ opts.on('--throttle-sec FLOAT', Float) { |v| options.throttle_sec = v }
76
+ opts.on('--sessions-dir PATH') { |v| options.sessions_dir = v }
77
+ opts.on('--session-file PATH') { |v| options.session_file = v }
78
+ opts.on('--poll-sec FLOAT', Float) { |v| options.poll_sec = v }
79
+ opts.on('--once') { options.once = true }
80
+ opts.on('--outbox-dir PATH') { |v| options.outbox_dir = v }
81
+ end
82
+
83
+ [parser, options]
84
+ end
85
+
86
+ def parse_args(argv = nil, stderr: $stderr, legacy_checkout_root: nil)
87
+ parser, options = build_parser
88
+ parser.parse!(argv || [])
89
+ return options if options.migrate_config
90
+
91
+ ConfigDiagnostics.warn_deprecated_cli_token(stderr:) if options.token_from_cli
92
+
93
+ sources = EnvSourceLoader.new(legacy_checkout_root:, stderr:).load(
94
+ path: options.env_file,
95
+ explicit: options.env_file_explicit,
96
+ config_path: options.config_file
97
+ )
98
+ policy = resolve_policy(sources, stderr:)
99
+ warn_ignored_repository_values(sources, policy:, stderr:)
100
+ apply_destination(options, sources, policy:, stderr:)
101
+
102
+ eligible = eligible_sources(sources, policy:)
103
+ trusted = trusted_sources(sources)
104
+ options.user_name ||= eligible.lookup('CODEX_NOTIFY_USER_NAME')&.value || system_user_name
105
+ options.title ||= eligible.lookup('CODEX_NOTIFY_TITLE')&.value
106
+ options.prompt ||= eligible.lookup('CODEX_PROMPT')&.value
107
+ options.outbox_dir ||= trusted.lookup('CODEX_NOTIFY_OUTBOX_DIR')&.value || DEFAULT_OUTBOX_DIR.to_s
108
+ options
109
+ rescue ConfigSupport::Error, EnvSourceLoader::Error, TrustedConfigLoader::Error, DestinationResolver::Error => e
110
+ raise Error, e.message
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CodexNotify
4
+ module ConfigDiagnostics
5
+ module_function
6
+
7
+ def warn_if_file_insecure(path, label:, stderr:)
8
+ stat = File.stat(path)
9
+ return unless stat.file?
10
+ return if (stat.mode & 0o077).zero?
11
+
12
+ permissions = format('%04o', stat.mode & 0o777)
13
+ stderr.puts(
14
+ "WARNING: #{label} #{path} has permissions #{permissions}; " \
15
+ "use `chmod 600 #{path}` to restrict access to secrets."
16
+ )
17
+ rescue NotImplementedError, SystemCallError
18
+ nil
19
+ end
20
+
21
+ def warn_if_env_file_insecure(path, stderr:)
22
+ warn_if_file_insecure(path, label: 'env file', stderr:)
23
+ end
24
+
25
+ def warn_deprecated_cli_token(stderr:)
26
+ stderr.puts(
27
+ 'WARNING: --token is deprecated because command-line arguments may be visible in process lists ' \
28
+ 'and shell history; use SLACK_BOT_TOKEN or a permission-restricted env file.'
29
+ )
30
+ end
31
+
32
+ def warn_deprecated_repository_credentials(path, keys, stderr:)
33
+ stderr.puts(
34
+ "WARNING: insecure legacy mode loaded repository Slack settings #{keys.join(' and ')} from automatically " \
35
+ "discovered env file #{path}; this temporary compatibility mode will be removed in a future major release. " \
36
+ 'Configure a trusted destination profile and use CODEX_NOTIFY_DESTINATION.'
37
+ )
38
+ end
39
+
40
+ def warn_ignored_repository_policy(path, stderr:)
41
+ stderr.puts(
42
+ "WARNING: ignored CODEX_NOTIFY_ENV_POLICY from automatically discovered repository env file #{path}; " \
43
+ 'the policy must come from trusted configuration.'
44
+ )
45
+ end
46
+
47
+ def warn_ignored_repository_credentials(path, keys, policy: nil, stderr:)
48
+ reason = policy ? " under the #{policy} policy" : ''
49
+ stderr.puts(
50
+ "WARNING: ignored #{keys.join(', ')} from automatically discovered repository env file #{path}#{reason}."
51
+ )
52
+ end
53
+
54
+ def warn_deprecated_tool_config(path, keys, stderr:)
55
+ stderr.puts(
56
+ "WARNING: #{keys.join(', ')} loaded from legacy codex-notify env file #{path}; " \
57
+ 'move trusted settings to the XDG config file.'
58
+ )
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dotenv'
4
+ require 'fileutils'
5
+ require 'pathname'
6
+ require 'tempfile'
7
+ require 'yaml'
8
+ require_relative 'config_diagnostics'
9
+ require_relative 'destination_name'
10
+ require_relative 'trusted_config_loader'
11
+
12
+ module CodexNotify
13
+ class ConfigMigrator
14
+ PROFILE_KEYS = {
15
+ 'SLACK_BOT_TOKEN__' => 'token',
16
+ 'SLACK_CHANNEL__' => 'channel'
17
+ }.freeze
18
+ ENV_POLICIES = %w[legacy restricted].freeze
19
+
20
+ class Error < StandardError; end
21
+
22
+ def initialize(legacy_checkout_root: nil, environment: ENV, stdout: $stdout, stderr: $stderr)
23
+ @legacy_checkout_root = Pathname(legacy_checkout_root).expand_path if legacy_checkout_root
24
+ @environment = environment
25
+ @stdout = stdout
26
+ @stderr = stderr
27
+ end
28
+
29
+ def run(env_path: nil, config_path: nil)
30
+ source = source_path(env_path)
31
+ target = target_path(config_path)
32
+ validate_paths(source, target)
33
+
34
+ ConfigDiagnostics.warn_if_env_file_insecure(source, stderr: @stderr)
35
+ values = Dotenv.parse(source.to_s)
36
+ document = build_document(values)
37
+ write_config(target, YAML.dump(document))
38
+ @stdout.puts("Created trusted config file #{target}.")
39
+ @stdout.puts('Verify the destination settings, then remove migrated secrets from the legacy env file manually.')
40
+ 0
41
+ rescue TrustedConfigLoader::Error => e
42
+ raise Error, e.message
43
+ rescue SystemCallError => e
44
+ raise Error, "could not migrate configuration: #{e.class}"
45
+ end
46
+
47
+ private
48
+
49
+ def source_path(path)
50
+ return Pathname(path).expand_path if path
51
+
52
+ unless @legacy_checkout_root
53
+ raise Error, '--migrate-config requires --env-file PATH outside a source checkout'
54
+ end
55
+
56
+ @legacy_checkout_root.join('.env')
57
+ end
58
+
59
+ def target_path(path)
60
+ return Pathname(path).expand_path if path
61
+
62
+ TrustedConfigLoader.new(environment: @environment, stderr: @stderr).default_path
63
+ end
64
+
65
+ def validate_paths(source, target)
66
+ raise Error, "legacy env file does not exist: #{source}" unless source.exist?
67
+ raise Error, "legacy env path is not a file: #{source}" unless source.file?
68
+ raise Error, 'legacy env file and config output path must be different' if source == target
69
+ raise Error, "config file already exists: #{target}" if target.exist?
70
+ end
71
+
72
+ def build_document(values)
73
+ document = {}
74
+ add_policy(document, values['CODEX_NOTIFY_ENV_POLICY'])
75
+ add_default_destination(document, values)
76
+ add_destinations(document, values)
77
+ raise Error, 'legacy env file contains no trusted settings to migrate' if document.empty?
78
+
79
+ document
80
+ end
81
+
82
+ def add_policy(document, raw_policy)
83
+ return if empty?(raw_policy)
84
+
85
+ policy = raw_policy.strip.downcase
86
+ unless ENV_POLICIES.include?(policy)
87
+ raise Error, "environment policy must be one of: #{ENV_POLICIES.join(', ')}"
88
+ end
89
+
90
+ document['env_policy'] = policy
91
+ end
92
+
93
+ def add_default_destination(document, values)
94
+ destination = {}
95
+ destination['token'] = values['SLACK_BOT_TOKEN'] unless empty?(values['SLACK_BOT_TOKEN'])
96
+ destination['channel'] = values['SLACK_CHANNEL'] unless empty?(values['SLACK_CHANNEL'])
97
+ document['default_destination'] = destination unless destination.empty?
98
+ end
99
+
100
+ def add_destinations(document, values)
101
+ destinations = {}
102
+ original_names = {}
103
+ values.each do |key, value|
104
+ next if empty?(value)
105
+
106
+ prefix, field = PROFILE_KEYS.find { |candidate, _| key.start_with?(candidate) }
107
+ next unless prefix
108
+
109
+ raw_name = key.delete_prefix(prefix)
110
+ name = DestinationName.normalize(raw_name)
111
+ previous = original_names[name]
112
+ if previous && previous != raw_name
113
+ raise Error, "destination name is duplicated after normalization: #{name}"
114
+ end
115
+
116
+ original_names[name] = raw_name
117
+ destinations[name] ||= {}
118
+ destinations[name][field] = value
119
+ end
120
+
121
+ destinations.sort.each do |name, destination|
122
+ raise Error, "destination #{name} must define channel" unless destination.key?('channel')
123
+ end
124
+ document['destinations'] = destinations.sort.to_h unless destinations.empty?
125
+ rescue DestinationName::Error => e
126
+ raise Error, e.message
127
+ end
128
+
129
+ def write_config(path, contents)
130
+ FileUtils.mkdir_p(path.dirname, mode: 0o700)
131
+ raise Error, "config file already exists: #{path}" if path.exist?
132
+
133
+ Tempfile.create(['codex-notify-config', '.tmp'], path.dirname.to_s) do |file|
134
+ file.chmod(0o600)
135
+ file.write(contents)
136
+ file.flush
137
+ file.fsync
138
+ File.link(file.path, path.to_s)
139
+ rescue Errno::EEXIST
140
+ raise Error, "config file already exists: #{path}"
141
+ end
142
+ end
143
+
144
+ def empty?(value)
145
+ !value || value.strip.empty?
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dotenv'
4
+ require 'etc'
5
+ require 'pathname'
6
+ require_relative 'config_diagnostics'
7
+
8
+ module CodexNotify
9
+ module ConfigSupport
10
+ class Error < StandardError; end
11
+
12
+ DEFAULT_ENV_PATH = '.env'
13
+ DEFAULT_ENV_POLICY = 'restricted'
14
+ ENV_POLICIES = %w[legacy restricted].freeze
15
+ REPOSITORY_ALLOWED_KEYS = %w[
16
+ CODEX_NOTIFY_DESTINATION
17
+ CODEX_NOTIFY_TITLE
18
+ CODEX_NOTIFY_USER_NAME
19
+ CODEX_NOTIFY_MODE
20
+ ].freeze
21
+ REPOSITORY_CREDENTIAL_PATTERN = /\ASLACK_(?:BOT_TOKEN|CHANNEL)(?:__.*)?\z/
22
+ def resolve_env_paths(path = DEFAULT_ENV_PATH, legacy_checkout_root: nil)
23
+ env_path = Pathname(path)
24
+ return [env_path] if env_path.absolute?
25
+
26
+ candidates = [Pathname(Dir.pwd).join(env_path)]
27
+ candidates << Pathname(legacy_checkout_root).join(env_path) if legacy_checkout_root
28
+ candidates.map(&:expand_path).select(&:exist?).each_with_object([]) do |candidate, paths|
29
+ paths << candidate unless paths.any? { |path| File.identical?(path, candidate) }
30
+ end
31
+ end
32
+
33
+ def load_env_file(path = DEFAULT_ENV_PATH, override: false, stderr: $stderr, legacy_checkout_root: nil)
34
+ env_paths = resolve_env_paths(path, legacy_checkout_root:)
35
+ return if env_paths.empty?
36
+
37
+ env_paths.each { |env_path| ConfigDiagnostics.warn_if_env_file_insecure(env_path, stderr:) }
38
+ loader = override ? Dotenv.method(:overload) : Dotenv.method(:load)
39
+ loader.call(*env_paths.map(&:to_s))
40
+ end
41
+
42
+ def system_user_name
43
+ ENV['USER'] || ENV['USERNAME'] || Etc.getlogin || 'user'
44
+ rescue StandardError
45
+ ENV['USER'] || ENV['USERNAME'] || 'user'
46
+ end
47
+
48
+ def getenv_any(keys)
49
+ keys.each do |key|
50
+ value = ENV[key]
51
+ return value if value && !value.empty?
52
+ end
53
+ nil
54
+ end
55
+
56
+ def add_common_options(parser, options)
57
+ parser.on('--config PATH') do |value|
58
+ options.config_file = value
59
+ end
60
+ parser.on('--migrate-config', 'Create trusted YAML from a legacy env file') do
61
+ options.migrate_config = true
62
+ end
63
+ parser.on('--env-file PATH') do |value|
64
+ options.env_file = value
65
+ options.env_file_explicit = true if options.respond_to?(:env_file_explicit=)
66
+ end
67
+ parser.on('--token TOKEN', 'Deprecated: use the XDG config file or SLACK_BOT_TOKEN') do |value|
68
+ options.token = value
69
+ options.token_from_cli = true
70
+ end
71
+ parser.on('--channel CHANNEL') { |value| options.channel = value }
72
+ parser.on('--destination NAME') { |value| options.destination = value } if options.respond_to?(:destination=)
73
+ parser.on('--user-name NAME') { |value| options.user_name = value }
74
+ parser.on('--title TITLE') { |value| options.title = value }
75
+ if options.respond_to?(:outbox_action=)
76
+ parser.on('--outbox-status') { options.outbox_action = :status }
77
+ parser.on('--drain-outbox') { options.outbox_action = :drain }
78
+ parser.on('--retry-outbox ID') do |value|
79
+ options.outbox_action = :retry
80
+ options.outbox_id = value
81
+ end
82
+ end
83
+ end
84
+
85
+ def resolve_policy(sources, stderr: $stderr)
86
+ result = trusted_sources(sources).lookup('CODEX_NOTIFY_ENV_POLICY')
87
+ policy = result ? result.value.strip.downcase : DEFAULT_ENV_POLICY
88
+ raise Error, "environment policy must be one of: #{ENV_POLICIES.join(', ')}" unless ENV_POLICIES.include?(policy)
89
+
90
+ warn_if_legacy_tool_source(result&.source, ['CODEX_NOTIFY_ENV_POLICY'], stderr:)
91
+ policy
92
+ end
93
+
94
+ def apply_destination(options, sources, policy:, stderr: $stderr)
95
+ eligible = eligible_sources(sources, policy:)
96
+ resolution = DestinationResolver.new(
97
+ selection_sources: sources,
98
+ profile_sources: trusted_sources(sources),
99
+ default_sources: eligible
100
+ ).resolve(destination: options.destination, token: options.token, channel: options.channel)
101
+
102
+ options.destination = resolution.destination
103
+ options.token = resolution.token
104
+ options.channel = resolution.channel
105
+
106
+ used_sources = [resolution.token_source, resolution.channel_source].compact
107
+ source_keys = [
108
+ [resolution.token_source, 'SLACK_BOT_TOKEN'],
109
+ [resolution.channel_source, 'SLACK_CHANNEL']
110
+ ]
111
+ repository_keys = source_keys.filter_map { |source, key| key if source&.kind == :repository }
112
+ if repository_keys.any?
113
+ source = used_sources.find { |candidate| candidate.kind == :repository }
114
+ ConfigDiagnostics.warn_deprecated_repository_credentials(source.path, repository_keys, stderr:)
115
+ end
116
+
117
+ tool_keys = [
118
+ [resolution.token_source, resolution.destination ? "destination #{resolution.destination} token" : 'SLACK_BOT_TOKEN'],
119
+ [resolution.channel_source, resolution.destination ? "destination #{resolution.destination} channel" : 'SLACK_CHANNEL']
120
+ ].filter_map { |source, key| key if source&.kind == :tool }
121
+ warn_if_legacy_tool_source(used_sources.find { |source| source.kind == :tool }, tool_keys, stderr:)
122
+ end
123
+
124
+ def eligible_sources(sources, policy:)
125
+ return sources unless policy == 'restricted'
126
+
127
+ sources.restrict_kind(:repository, keys: REPOSITORY_ALLOWED_KEYS)
128
+ end
129
+
130
+ def trusted_sources(sources)
131
+ sources.excluding_kind(:repository)
132
+ end
133
+
134
+ def warn_ignored_repository_values(sources, policy:, stderr:)
135
+ sources.select { |source| source.kind == :repository }.each do |source|
136
+ policy_value = source.values['CODEX_NOTIFY_ENV_POLICY']
137
+ if policy_value && !policy_value.empty?
138
+ ConfigDiagnostics.warn_ignored_repository_policy(source.path, stderr:)
139
+ end
140
+
141
+ ignored = source.values.keys.grep(REPOSITORY_CREDENTIAL_PATTERN)
142
+ ignored.select! { |key| key.include?('__') } if policy == 'legacy'
143
+ next if ignored.empty?
144
+
145
+ reason = policy == 'restricted' ? policy : nil
146
+ ConfigDiagnostics.warn_ignored_repository_credentials(source.path, ignored.sort, policy: reason, stderr:)
147
+ end
148
+ end
149
+
150
+ def warn_if_legacy_tool_source(source, keys, stderr:)
151
+ return unless source&.kind == :tool && keys.any?
152
+
153
+ ConfigDiagnostics.warn_deprecated_tool_config(source.path, keys, stderr:)
154
+ end
155
+
156
+ end
157
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CodexNotify
4
+ module DestinationName
5
+ PATTERN = /\A[A-Z0-9_]+\z/
6
+
7
+ class Error < StandardError; end
8
+
9
+ module_function
10
+
11
+ def normalize(value)
12
+ normalized = value.to_s.strip.upcase
13
+ return normalized if PATTERN.match?(normalized)
14
+
15
+ raise Error, 'destination must contain only A-Z, 0-9, and _'
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'destination_name'
4
+
5
+ module CodexNotify
6
+ class DestinationResolver
7
+ class Error < StandardError; end
8
+
9
+ Resolution = Struct.new(
10
+ :destination,
11
+ :token,
12
+ :channel,
13
+ :token_source,
14
+ :channel_source,
15
+ keyword_init: true
16
+ )
17
+
18
+ def initialize(selection_sources:, profile_sources:, default_sources:)
19
+ @selection_sources = selection_sources
20
+ @profile_sources = profile_sources
21
+ @default_sources = default_sources
22
+ end
23
+
24
+ def resolve(destination:, token:, channel:)
25
+ selected = destination || selection_sources.lookup('CODEX_NOTIFY_DESTINATION')&.value
26
+ return resolve_default(token:, channel:) unless selected
27
+
28
+ resolve_profile(DestinationName.normalize(selected), token:, channel:)
29
+ rescue DestinationName::Error => e
30
+ raise Error, e.message
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :selection_sources, :profile_sources, :default_sources
36
+
37
+ def resolve_profile(destination, token:, channel:)
38
+ channel_key = "SLACK_CHANNEL__#{destination}"
39
+ profile_channel = profile_sources.lookup(channel_key)
40
+ raise Error, "destination #{destination} is not configured: missing #{channel_key}" unless profile_channel
41
+
42
+ profile_token = profile_sources.lookup("SLACK_BOT_TOKEN__#{destination}") unless token
43
+ default_token = profile_sources.lookup('SLACK_BOT_TOKEN') unless token || profile_token
44
+ resolved_token = token || profile_token&.value || default_token&.value
45
+ raise Error, "destination #{destination} has no Slack bot token" unless resolved_token
46
+
47
+ Resolution.new(
48
+ destination:,
49
+ token: resolved_token,
50
+ channel: channel || profile_channel.value,
51
+ token_source: token ? nil : (profile_token || default_token)&.source,
52
+ channel_source: channel ? nil : profile_channel.source
53
+ )
54
+ end
55
+
56
+ def resolve_default(token:, channel:)
57
+ token_result = default_sources.lookup('SLACK_BOT_TOKEN') unless token
58
+ channel_result = default_sources.lookup('SLACK_CHANNEL') unless channel
59
+ Resolution.new(
60
+ destination: nil,
61
+ token: token || token_result&.value,
62
+ channel: channel || channel_result&.value,
63
+ token_source: token_result&.source,
64
+ channel_source: channel_result&.source
65
+ )
66
+ end
67
+ end
68
+ end