kward 0.76.0 → 0.78.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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +8 -1
- data/CHANGELOG.md +62 -0
- data/Gemfile.lock +2 -2
- data/README.md +13 -4
- data/doc/agent-tools.md +0 -3
- data/doc/api.md +4 -0
- data/doc/code-search.md +9 -6
- data/doc/composer.md +219 -0
- data/doc/configuration.md +114 -27
- data/doc/editor.md +18 -2
- data/doc/files.md +1 -1
- data/doc/getting-started.md +4 -0
- data/doc/git.md +2 -2
- data/doc/lifecycle-hooks.md +2 -14
- data/doc/pan.md +167 -0
- data/doc/permissions.md +179 -0
- data/doc/plugins.md +58 -0
- data/doc/rpc.md +61 -22
- data/doc/security.md +184 -0
- data/doc/tabs.md +4 -3
- data/doc/usage.md +15 -32
- data/doc/web-search.md +4 -2
- data/lib/kward/ansi.rb +2 -2
- data/lib/kward/auth/anthropic_oauth.rb +2 -2
- data/lib/kward/auth/github_oauth.rb +3 -3
- data/lib/kward/auth/oauth_helpers.rb +4 -2
- data/lib/kward/auth/openai_oauth.rb +2 -1
- data/lib/kward/cancellation.rb +2 -2
- data/lib/kward/cli/commands.rb +1 -3
- data/lib/kward/cli/interactive_turn.rb +0 -17
- data/lib/kward/cli/plugins.rb +24 -7
- data/lib/kward/cli/prompt_interface.rb +1 -0
- data/lib/kward/cli/rendering.rb +7 -1
- data/lib/kward/cli/runtime_helpers.rb +11 -35
- data/lib/kward/cli/sessions.rb +14 -5
- data/lib/kward/cli/settings.rb +22 -0
- data/lib/kward/cli/slash_commands.rb +1 -561
- data/lib/kward/cli/tabs.rb +154 -36
- data/lib/kward/cli.rb +30 -26
- data/lib/kward/compactor.rb +3 -3
- data/lib/kward/config_files.rb +28 -0
- data/lib/kward/ekwsh.rb +0 -4
- data/lib/kward/hooks/catalog.rb +0 -5
- data/lib/kward/hooks/http_handler.rb +2 -1
- data/lib/kward/http.rb +18 -0
- data/lib/kward/image_attachments.rb +1 -1
- data/lib/kward/model/client.rb +33 -11
- data/lib/kward/model/payloads.rb +6 -1
- data/lib/kward/openrouter_model_cache.rb +2 -1
- data/lib/kward/pan/index.html.erb +459 -64
- data/lib/kward/pan/kward_logo.png +0 -0
- data/lib/kward/pan/server.rb +248 -23
- data/lib/kward/permissions/policy.rb +171 -0
- data/lib/kward/plugin_registry.rb +71 -1
- data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
- data/lib/kward/prompt_interface/editor/auto_indent.rb +31 -23
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +72 -3
- data/lib/kward/prompt_interface/layout.rb +0 -4
- data/lib/kward/prompt_interface/project_browser.rb +40 -1
- data/lib/kward/prompt_interface/question_prompt.rb +12 -3
- data/lib/kward/prompt_interface/runtime_state.rb +4 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +0 -1
- data/lib/kward/prompt_interface/transcript_renderer.rb +0 -12
- data/lib/kward/prompt_interface.rb +20 -1
- data/lib/kward/prompts/commands.rb +1 -3
- data/lib/kward/prompts.rb +20 -0
- data/lib/kward/rpc/plugin_chat_manager.rb +299 -0
- data/lib/kward/rpc/server.rb +48 -41
- data/lib/kward/rpc/session_manager.rb +76 -25
- data/lib/kward/rpc/tool_event_normalizer.rb +2 -0
- data/lib/kward/rpc/tool_metadata.rb +23 -0
- data/lib/kward/rpc/transcript_normalizer.rb +18 -5
- data/lib/kward/session_store.rb +55 -16
- data/lib/kward/skills/registry.rb +21 -1
- data/lib/kward/starter_pack_installer.rb +3 -1
- data/lib/kward/tab_driver.rb +87 -0
- data/lib/kward/tab_store.rb +74 -12
- data/lib/kward/tools/base.rb +14 -0
- data/lib/kward/tools/code_search.rb +8 -2
- data/lib/kward/tools/fetch_content.rb +4 -2
- data/lib/kward/tools/fetch_raw.rb +3 -1
- data/lib/kward/tools/registry.rb +45 -17
- data/lib/kward/tools/search/code.rb +47 -13
- data/lib/kward/tools/search/web.rb +61 -18
- data/lib/kward/tools/search/web_fetch.rb +206 -38
- data/lib/kward/tools/tool_call.rb +0 -4
- data/lib/kward/tools/web_search.rb +3 -1
- data/lib/kward/transcript_export.rb +1 -1
- data/lib/kward/update_check.rb +2 -1
- data/lib/kward/version.rb +1 -1
- data/templates/default/kward_navigation.rb +7 -2
- data/templates/default/layout/html/layout.erb +2 -2
- data/templates/default/layout/html/setup.rb +1 -0
- metadata +11 -12
- data/lib/kward/workers/git_guard.rb +0 -93
- data/lib/kward/workers/job.rb +0 -99
- data/lib/kward/workers/live_view.rb +0 -49
- data/lib/kward/workers/manager.rb +0 -328
- data/lib/kward/workers/queue_runner.rb +0 -166
- data/lib/kward/workers/queue_store.rb +0 -112
- data/lib/kward/workers/store.rb +0 -72
- data/lib/kward/workers/tool_policy.rb +0 -23
- data/lib/kward/workers/worker.rb +0 -82
- data/lib/kward/workers/write_lock.rb +0 -38
- data/lib/kward/workers.rb +0 -10
data/lib/kward/pan/server.rb
CHANGED
|
@@ -13,22 +13,28 @@ require_relative "../plugin_registry"
|
|
|
13
13
|
require_relative "../prompts/commands"
|
|
14
14
|
require_relative "../rpc/transcript_normalizer"
|
|
15
15
|
require_relative "../session_store"
|
|
16
|
+
require_relative "../session_trash"
|
|
16
17
|
require_relative "../tools/tool_call"
|
|
17
18
|
require_relative "../tools/registry"
|
|
19
|
+
require_relative "../version"
|
|
18
20
|
require_relative "../workspace"
|
|
19
21
|
|
|
20
22
|
# Namespace for the Kward CLI agent runtime.
|
|
21
23
|
module Kward
|
|
22
|
-
#
|
|
24
|
+
# Local HTTP server for the mobile-friendly Pan web UI.
|
|
23
25
|
class PanServer
|
|
24
26
|
DEFAULT_HOST = "0.0.0.0"
|
|
25
27
|
DEFAULT_PORT = 8765
|
|
26
28
|
MAX_REQUEST_BODY_BYTES = 64 * 1024
|
|
29
|
+
ROUTING_PROBE_ADDRESS = "8.8.8.8"
|
|
30
|
+
ROUTING_PROBE_PORT = 80
|
|
27
31
|
|
|
28
|
-
def initialize(client:, working_directory:, config: ConfigFiles.read_config, config_dir: ConfigFiles.config_dir, output: $stderr)
|
|
32
|
+
def initialize(client:, working_directory:, config: ConfigFiles.read_config, config_dir: ConfigFiles.config_dir, output: $stderr, udp_socket_class: UDPSocket)
|
|
29
33
|
@client = client
|
|
30
34
|
@output = output
|
|
35
|
+
@udp_socket_class = udp_socket_class
|
|
31
36
|
@workspace = Workspace.new(root: working_directory)
|
|
37
|
+
@full_config = config
|
|
32
38
|
@config = pan_config(config)
|
|
33
39
|
@host = @config.fetch("host", DEFAULT_HOST).to_s
|
|
34
40
|
@port = positive_port(@config.fetch("port", DEFAULT_PORT))
|
|
@@ -37,28 +43,22 @@ module Kward
|
|
|
37
43
|
raise "Pan mode requires pan_mode.username and pan_mode.password in #{ConfigFiles.config_path}" if @username.empty? || @password.empty?
|
|
38
44
|
|
|
39
45
|
@session_store = SessionStore.new(config_dir: config_dir, cwd: @workspace.root.to_s)
|
|
40
|
-
@session = @session_store.create
|
|
41
|
-
|
|
42
|
-
workspace_root: @workspace.root.to_s,
|
|
46
|
+
@session = @session_store.create(
|
|
47
|
+
provider: (@client.current_provider if @client.respond_to?(:current_provider)),
|
|
43
48
|
model: (@client.current_model if @client.respond_to?(:current_model)),
|
|
44
49
|
reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort))
|
|
45
50
|
)
|
|
51
|
+
@conversation = new_conversation
|
|
46
52
|
@session.attach(@conversation)
|
|
47
|
-
|
|
48
|
-
hook_manager = lifecycle_hook_manager
|
|
49
|
-
@agent = Agent.new(
|
|
50
|
-
client: @client,
|
|
51
|
-
tool_registry: ToolRegistry.new(workspace: @workspace, ask_user_question_enabled: false, hook_manager: hook_manager, hook_context: hook_context),
|
|
52
|
-
conversation: @conversation,
|
|
53
|
-
hook_manager: hook_manager,
|
|
54
|
-
hook_context: hook_context
|
|
55
|
-
)
|
|
53
|
+
@agent = build_agent
|
|
56
54
|
@prompt_queue = Queue.new
|
|
57
55
|
@subscribers = []
|
|
58
56
|
@subscribers_mutex = Mutex.new
|
|
59
57
|
@worker_started = false
|
|
60
58
|
@active = false
|
|
59
|
+
@pending_turns = 0
|
|
61
60
|
@state_mutex = Mutex.new
|
|
61
|
+
@session_mutex = Mutex.new
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
attr_reader :host, :port, :session, :workspace
|
|
@@ -91,8 +91,11 @@ module Kward
|
|
|
91
91
|
text = prompt.to_s
|
|
92
92
|
return { ok: false, error: "Prompt is required" } if text.strip.empty?
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
@session_mutex.synchronize do
|
|
95
|
+
queued_at = Time.now.utc.iso8601(3)
|
|
96
|
+
@state_mutex.synchronize { @pending_turns += 1 }
|
|
97
|
+
@prompt_queue << { prompt: text, queued_at: queued_at }
|
|
98
|
+
end
|
|
96
99
|
broadcast("queue", queue_payload)
|
|
97
100
|
{ ok: true, queued: @prompt_queue.size, active: active? }
|
|
98
101
|
end
|
|
@@ -103,6 +106,27 @@ module Kward
|
|
|
103
106
|
|
|
104
107
|
private
|
|
105
108
|
|
|
109
|
+
def new_conversation
|
|
110
|
+
Conversation.new(
|
|
111
|
+
workspace_root: @workspace.root.to_s,
|
|
112
|
+
provider: (@client.current_provider if @client.respond_to?(:current_provider)),
|
|
113
|
+
model: (@client.current_model if @client.respond_to?(:current_model)),
|
|
114
|
+
reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort))
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def build_agent
|
|
119
|
+
hook_context = lifecycle_hook_context
|
|
120
|
+
hook_manager = lifecycle_hook_manager
|
|
121
|
+
Agent.new(
|
|
122
|
+
client: @client,
|
|
123
|
+
tool_registry: ToolRegistry.new(workspace: @workspace, ask_user_question_enabled: false, hook_manager: hook_manager, hook_context: hook_context),
|
|
124
|
+
conversation: @conversation,
|
|
125
|
+
hook_manager: hook_manager,
|
|
126
|
+
hook_context: hook_context
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
106
130
|
def lifecycle_hook_manager
|
|
107
131
|
manager = Hooks::ConfigLoader.new(ConfigFiles.lifecycle_hooks_config(@workspace.root)).manager
|
|
108
132
|
manager.on_result = method(:broadcast_lifecycle_hook_event)
|
|
@@ -185,7 +209,10 @@ module Kward
|
|
|
185
209
|
broadcast("error", { message: e.message })
|
|
186
210
|
broadcast("turn_finished", queue_payload.merge(status: "failed"))
|
|
187
211
|
ensure
|
|
188
|
-
|
|
212
|
+
@state_mutex.synchronize do
|
|
213
|
+
@active = false
|
|
214
|
+
@pending_turns -= 1 if @pending_turns.positive?
|
|
215
|
+
end
|
|
189
216
|
broadcast("queue", queue_payload)
|
|
190
217
|
end
|
|
191
218
|
|
|
@@ -201,12 +228,18 @@ module Kward
|
|
|
201
228
|
case [request[:method], request[:path]]
|
|
202
229
|
when ["GET", "/"]
|
|
203
230
|
write_response(socket, 200, { "Content-Type" => "text/html; charset=utf-8" }, render_index)
|
|
231
|
+
when ["GET", "/kward-logo.png"]
|
|
232
|
+
write_response(socket, 200, { "Content-Type" => "image/png", "Cache-Control" => "public, max-age=86400" }, File.binread(File.join(__dir__, "kward_logo.png")))
|
|
204
233
|
when ["GET", "/transcript"]
|
|
205
|
-
write_json(socket, 200, transcript: transcript_items, session:
|
|
234
|
+
write_json(socket, 200, transcript: transcript_items, session: active_session_payload, workspace: @workspace.root.to_s)
|
|
235
|
+
when ["GET", "/sessions"]
|
|
236
|
+
write_json(socket, 200, sessions: session_payloads, activeSessionId: @session.id)
|
|
206
237
|
when ["GET", "/events"]
|
|
207
238
|
stream_events(socket)
|
|
208
239
|
when ["POST", "/turn"]
|
|
209
|
-
handle_turn(socket, request[:body])
|
|
240
|
+
handle_turn(socket, request[:body], request[:headers])
|
|
241
|
+
when ["POST", "/sessions"]
|
|
242
|
+
handle_session_action(socket, request[:body], request[:headers])
|
|
210
243
|
else
|
|
211
244
|
write_response(socket, 404, { "Content-Type" => "text/plain; charset=utf-8" }, "Not found\n")
|
|
212
245
|
end
|
|
@@ -254,7 +287,9 @@ module Kward
|
|
|
254
287
|
left.bytes.zip(right.bytes).reduce(0) { |memo, pair| memo | (pair[0] ^ pair[1]) }.zero?
|
|
255
288
|
end
|
|
256
289
|
|
|
257
|
-
def handle_turn(socket, body)
|
|
290
|
+
def handle_turn(socket, body, headers)
|
|
291
|
+
return write_json(socket, 415, ok: false, error: "Content-Type must be application/json") unless json_request?(headers)
|
|
292
|
+
|
|
258
293
|
params = JSON.parse(body.empty? ? "{}" : body)
|
|
259
294
|
result = enqueue_prompt(params["prompt"])
|
|
260
295
|
status = result[:ok] ? 202 : 422
|
|
@@ -263,6 +298,155 @@ module Kward
|
|
|
263
298
|
write_json(socket, 400, ok: false, error: "Invalid JSON")
|
|
264
299
|
end
|
|
265
300
|
|
|
301
|
+
def handle_session_action(socket, body, headers)
|
|
302
|
+
return write_json(socket, 415, ok: false, error: "Content-Type must be application/json") unless json_request?(headers)
|
|
303
|
+
|
|
304
|
+
params = JSON.parse(body.empty? ? "{}" : body)
|
|
305
|
+
result = update_session(params)
|
|
306
|
+
write_json(socket, result.delete(:status), result)
|
|
307
|
+
rescue JSON::ParserError
|
|
308
|
+
write_json(socket, 400, ok: false, error: "Invalid JSON")
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def update_session(params)
|
|
312
|
+
return { status: 409, ok: false, error: "Wait for queued turns to finish before changing sessions" } if turns_pending?
|
|
313
|
+
|
|
314
|
+
result = @session_mutex.synchronize do
|
|
315
|
+
return { status: 409, ok: false, error: "Wait for queued turns to finish before changing sessions" } if turns_pending?
|
|
316
|
+
|
|
317
|
+
case params["action"]
|
|
318
|
+
when "new" then create_session
|
|
319
|
+
when "resume" then resume_session(params["id"])
|
|
320
|
+
when "rename" then rename_session(params["id"], params["name"])
|
|
321
|
+
when "delete" then delete_session(params["id"])
|
|
322
|
+
else { status: 422, ok: false, error: "Unknown session action" }
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
broadcast("session_changed", result[:session]) if result[:ok] && result[:session]
|
|
326
|
+
result
|
|
327
|
+
rescue StandardError => e
|
|
328
|
+
{ status: 422, ok: false, error: e.message }
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def create_session
|
|
332
|
+
previous = @session
|
|
333
|
+
session = @session_store.create(
|
|
334
|
+
provider: (@client.current_provider if @client.respond_to?(:current_provider)),
|
|
335
|
+
model: (@client.current_model if @client.respond_to?(:current_model)),
|
|
336
|
+
reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort))
|
|
337
|
+
)
|
|
338
|
+
conversation = new_conversation
|
|
339
|
+
session.attach(conversation)
|
|
340
|
+
activate_session(session, conversation)
|
|
341
|
+
previous.delete_if_unused
|
|
342
|
+
{ status: 200, ok: true, session: active_session_payload }
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def resume_session(id)
|
|
346
|
+
info = find_session(id)
|
|
347
|
+
return { status: 404, ok: false, error: "Session not found" } unless info
|
|
348
|
+
return { status: 200, ok: true, session: active_session_payload } if info.id == @session.id
|
|
349
|
+
|
|
350
|
+
previous = @session
|
|
351
|
+
session, conversation = @session_store.load(
|
|
352
|
+
info.path,
|
|
353
|
+
workspace: @workspace,
|
|
354
|
+
provider: (@client.current_provider if @client.respond_to?(:current_provider)),
|
|
355
|
+
model: (@client.current_model if @client.respond_to?(:current_model)),
|
|
356
|
+
reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort))
|
|
357
|
+
)
|
|
358
|
+
activate_session(session, conversation)
|
|
359
|
+
previous.delete_if_unused
|
|
360
|
+
{ status: 200, ok: true, session: active_session_payload }
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def rename_session(id, name)
|
|
364
|
+
clean_name = name.to_s.strip
|
|
365
|
+
return { status: 422, ok: false, error: "Session name is too long" } if clean_name.length > 120
|
|
366
|
+
|
|
367
|
+
target = id.to_s == @session.id ? @session : find_session(id)
|
|
368
|
+
return { status: 404, ok: false, error: "Session not found" } unless target
|
|
369
|
+
|
|
370
|
+
session = target.respond_to?(:rename) ? target : @session_store.load(target.path, workspace: @workspace).first
|
|
371
|
+
session.rename(clean_name)
|
|
372
|
+
@session.name = session.name if session.id == @session.id
|
|
373
|
+
{ status: 200, ok: true, session: session_payload(session) }
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def delete_session(id)
|
|
377
|
+
if id.to_s == @session.id
|
|
378
|
+
deleted_id = @session.id
|
|
379
|
+
deleted_path = @session.path
|
|
380
|
+
create_session
|
|
381
|
+
SessionTrash.new.delete(deleted_path)
|
|
382
|
+
return { status: 200, ok: true, deletedSessionId: deleted_id, session: active_session_payload }
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
info = find_session(id)
|
|
386
|
+
return { status: 404, ok: false, error: "Session not found" } unless info
|
|
387
|
+
|
|
388
|
+
SessionTrash.new.delete(info.path)
|
|
389
|
+
{ status: 200, ok: true, deletedSessionId: info.id }
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def activate_session(session, conversation)
|
|
393
|
+
@session = session
|
|
394
|
+
@conversation = conversation
|
|
395
|
+
@agent = build_agent
|
|
396
|
+
@session_store.remember_last_session(session)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def find_session(id)
|
|
400
|
+
session_infos.find { |info| info.id.to_s == id.to_s }
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def session_infos
|
|
404
|
+
@session_store.recent(limit: 50, keep_empty_path: @session.path)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def session_payloads
|
|
408
|
+
sessions = session_infos.map { |info| session_payload(info) }
|
|
409
|
+
sessions.unshift(active_session_payload) unless sessions.any? { |item| item[:id] == @session.id }
|
|
410
|
+
sessions.sort_by { |item| item[:modifiedAt].to_s }.reverse
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def active_session_payload
|
|
414
|
+
info = session_infos.find { |item| item.id == @session.id }
|
|
415
|
+
return session_payload(info).merge(active: true, path: @session.path) if info
|
|
416
|
+
|
|
417
|
+
session_payload(@session).merge(
|
|
418
|
+
active: true,
|
|
419
|
+
path: @session.path,
|
|
420
|
+
modifiedAt: File.mtime(@session.path).utc.iso8601(3),
|
|
421
|
+
preview: transcript_items.find { |item| item[:role] == "user" }&.fetch(:text, "") || "",
|
|
422
|
+
messageCount: transcript_items.length
|
|
423
|
+
)
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def session_payload(session)
|
|
427
|
+
name = session.name.to_s.strip
|
|
428
|
+
preview = session.respond_to?(:first_message) ? session.first_message.to_s : ""
|
|
429
|
+
{
|
|
430
|
+
id: session.id,
|
|
431
|
+
name: name.empty? ? nil : name,
|
|
432
|
+
title: name.empty? ? (preview.empty? ? "New session" : preview) : name,
|
|
433
|
+
preview: preview,
|
|
434
|
+
createdAt: session.created_at&.utc&.iso8601(3),
|
|
435
|
+
modifiedAt: (session.respond_to?(:modified_at) ? session.modified_at&.utc&.iso8601(3) : nil),
|
|
436
|
+
messageCount: (session.respond_to?(:message_count) ? session.message_count.to_i : 0),
|
|
437
|
+
model: (session.respond_to?(:model) ? session.model : @conversation.model),
|
|
438
|
+
active: session.id == @session.id
|
|
439
|
+
}
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def turns_pending?
|
|
443
|
+
@state_mutex.synchronize { @pending_turns.positive? }
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def json_request?(headers)
|
|
447
|
+
headers["content-type"].to_s.downcase.start_with?("application/json")
|
|
448
|
+
end
|
|
449
|
+
|
|
266
450
|
def stream_events(socket)
|
|
267
451
|
subscriber = Queue.new
|
|
268
452
|
subscribe(subscriber)
|
|
@@ -316,11 +500,38 @@ module Kward
|
|
|
316
500
|
|
|
317
501
|
def render_index
|
|
318
502
|
@workspace_root = @workspace.root.to_s
|
|
503
|
+
@workspace_label = pan_workspace_label
|
|
504
|
+
@assistant_label = assistant_label
|
|
319
505
|
@session_path = @session.path
|
|
506
|
+
@version = Kward::VERSION
|
|
320
507
|
template = File.read(File.join(__dir__, "index.html.erb"))
|
|
321
508
|
ERB.new(template).result(binding)
|
|
322
509
|
end
|
|
323
510
|
|
|
511
|
+
def assistant_label
|
|
512
|
+
ConfigFiles.active_persona_label(
|
|
513
|
+
workspace_root: @conversation.workspace_root,
|
|
514
|
+
model: @conversation.model,
|
|
515
|
+
config: @full_config
|
|
516
|
+
) || "Assistant"
|
|
517
|
+
rescue StandardError
|
|
518
|
+
"Assistant"
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def pan_workspace_label
|
|
522
|
+
root = File.expand_path(@workspace.root.to_s)
|
|
523
|
+
home = Dir.home
|
|
524
|
+
if root == home || root.start_with?("#{home}/")
|
|
525
|
+
relative = root.delete_prefix(home).delete_prefix("/")
|
|
526
|
+
return "~" if relative.empty?
|
|
527
|
+
return "~/#{relative}" unless relative.include?("/")
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
[File.basename(File.dirname(root)), File.basename(root)].reject(&:empty?).join("/")
|
|
531
|
+
rescue StandardError
|
|
532
|
+
@workspace.root.to_s
|
|
533
|
+
end
|
|
534
|
+
|
|
324
535
|
def pan_config(config)
|
|
325
536
|
values = config["pan_mode"]
|
|
326
537
|
values.is_a?(Hash) ? values : {}
|
|
@@ -332,7 +543,19 @@ module Kward
|
|
|
332
543
|
end
|
|
333
544
|
|
|
334
545
|
def display_host
|
|
335
|
-
@host == "0.0.0.0"
|
|
546
|
+
return @host unless @host == "0.0.0.0"
|
|
547
|
+
|
|
548
|
+
lan_address || "<lan-address>"
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def lan_address
|
|
552
|
+
socket = @udp_socket_class.new
|
|
553
|
+
socket.connect(ROUTING_PROBE_ADDRESS, ROUTING_PROBE_PORT)
|
|
554
|
+
socket.addr.last
|
|
555
|
+
rescue SocketError, SystemCallError
|
|
556
|
+
nil
|
|
557
|
+
ensure
|
|
558
|
+
socket&.close
|
|
336
559
|
end
|
|
337
560
|
|
|
338
561
|
def set_active(value)
|
|
@@ -385,9 +608,9 @@ module Kward
|
|
|
385
608
|
text.to_s.empty? ? nil : { role: "reasoning", label: "Reasoning", text: text }
|
|
386
609
|
when "text"
|
|
387
610
|
text = part[:text] || part["text"]
|
|
388
|
-
text.to_s.empty? ? nil : { role: "assistant", label:
|
|
611
|
+
text.to_s.empty? ? nil : { role: "assistant", label: assistant_label, text: text }
|
|
389
612
|
when "image"
|
|
390
|
-
{ role: "assistant", label:
|
|
613
|
+
{ role: "assistant", label: assistant_label, text: image_part_text(part) }
|
|
391
614
|
when "toolCall"
|
|
392
615
|
{ role: "tool", label: "Tool", text: tool_call_part_text(part) }
|
|
393
616
|
end
|
|
@@ -442,6 +665,8 @@ module Kward
|
|
|
442
665
|
when 400 then "Bad Request"
|
|
443
666
|
when 401 then "Unauthorized"
|
|
444
667
|
when 404 then "Not Found"
|
|
668
|
+
when 409 then "Conflict"
|
|
669
|
+
when 415 then "Unsupported Media Type"
|
|
445
670
|
when 422 then "Unprocessable Content"
|
|
446
671
|
else "Internal Server Error"
|
|
447
672
|
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
# Namespace for Kward's permission-policy runtime.
|
|
4
|
+
module Kward
|
|
5
|
+
module Permissions
|
|
6
|
+
# Evaluates the opt-in permission policy for model-requested tool calls.
|
|
7
|
+
#
|
|
8
|
+
# The policy is intentionally a decision layer, not a process sandbox. It
|
|
9
|
+
# can prevent Kward from starting a tool, but it cannot constrain a shell
|
|
10
|
+
# command after it has begun. OS-enforced sandboxing belongs at a lower
|
|
11
|
+
# process boundary.
|
|
12
|
+
class Policy
|
|
13
|
+
MODES = %w[ask read-only workspace-write deny-by-default].freeze
|
|
14
|
+
NETWORK_TOOLS = %w[web_search fetch_content fetch_raw].freeze
|
|
15
|
+
FILE_CHANGE_TOOLS = %w[write_file edit_file].freeze
|
|
16
|
+
|
|
17
|
+
Decision = Struct.new(:action, :reason, keyword_init: true) do
|
|
18
|
+
def allowed?
|
|
19
|
+
action == :allow
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def approval_required?
|
|
23
|
+
action == :ask
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def denied?
|
|
27
|
+
action == :deny
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.from_config(config)
|
|
32
|
+
permissions = config["permissions"].is_a?(Hash) ? config["permissions"] : {}
|
|
33
|
+
new(
|
|
34
|
+
enabled: permissions["enabled"] == true,
|
|
35
|
+
mode: permissions["mode"],
|
|
36
|
+
allow: permissions["allow"],
|
|
37
|
+
ask: permissions["ask"],
|
|
38
|
+
deny: permissions["deny"],
|
|
39
|
+
write_scopes: permissions.key?("write_scopes") ? permissions["write_scopes"] : nil
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def initialize(enabled: false, mode: "ask", allow: [], ask: [], deny: [], write_scopes: nil)
|
|
44
|
+
@enabled = enabled == true
|
|
45
|
+
@mode = normalize_mode(mode)
|
|
46
|
+
@allow = normalize_rules(allow)
|
|
47
|
+
@ask = normalize_rules(ask)
|
|
48
|
+
@deny = normalize_rules(deny)
|
|
49
|
+
@write_scopes = normalize_scopes(write_scopes)
|
|
50
|
+
@session_allowed_tools = {}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def enabled?
|
|
54
|
+
@enabled
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Allows this model tool for the remaining lifetime of this policy object.
|
|
58
|
+
# A matching deny or ask rule still takes precedence over this temporary grant.
|
|
59
|
+
def allow_for_session!(tool_name)
|
|
60
|
+
@session_allowed_tools[tool_name.to_s] = true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def decision_for(tool_name, arguments, source: nil)
|
|
64
|
+
return Decision.new(action: :allow, reason: "permissions disabled") unless enabled?
|
|
65
|
+
|
|
66
|
+
request = request_for(tool_name, arguments, source: source)
|
|
67
|
+
return Decision.new(action: :deny, reason: "matched deny rule") if matches?(@deny, request)
|
|
68
|
+
return Decision.new(action: :ask, reason: "matched ask rule") if matches?(@ask, request)
|
|
69
|
+
return Decision.new(action: :allow, reason: "matched allow rule") if matches?(@allow, request)
|
|
70
|
+
return Decision.new(action: :allow, reason: "allowed for this session") if @session_allowed_tools[request.fetch("tool")]
|
|
71
|
+
|
|
72
|
+
default_decision(request)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def normalize_mode(mode)
|
|
78
|
+
value = mode.to_s
|
|
79
|
+
MODES.include?(value) ? value : "ask"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def normalize_rules(rules)
|
|
83
|
+
Array(rules).filter_map do |rule|
|
|
84
|
+
next unless rule.is_a?(Hash)
|
|
85
|
+
|
|
86
|
+
rule.transform_keys(&:to_s).slice("tool", "path", "host", "command", "source")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def normalize_scopes(scopes)
|
|
91
|
+
return nil if scopes.nil?
|
|
92
|
+
|
|
93
|
+
Array(scopes).map(&:to_s).reject(&:empty?)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def request_for(tool_name, arguments, source:)
|
|
97
|
+
name = tool_name.to_s
|
|
98
|
+
args = arguments.to_h
|
|
99
|
+
{
|
|
100
|
+
"tool" => name,
|
|
101
|
+
"path" => args["path"] || args[:path],
|
|
102
|
+
"host" => request_host(name, args),
|
|
103
|
+
"command" => args["command"] || args[:command],
|
|
104
|
+
"source" => source.to_s
|
|
105
|
+
}.compact.transform_values(&:to_s)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def request_host(name, arguments)
|
|
109
|
+
return nil unless %w[fetch_content fetch_raw].include?(name)
|
|
110
|
+
|
|
111
|
+
URI.parse((arguments["url"] || arguments[:url]).to_s).host
|
|
112
|
+
rescue URI::InvalidURIError
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def matches?(rules, request)
|
|
117
|
+
rules.any? { |rule| rule_matches?(rule, request) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def rule_matches?(rule, request)
|
|
121
|
+
return false if rule.empty?
|
|
122
|
+
|
|
123
|
+
rule.all? do |field, pattern|
|
|
124
|
+
value = request[field]
|
|
125
|
+
value && glob_match?(pattern.to_s, value)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def glob_match?(pattern, value)
|
|
130
|
+
expression = Regexp.escape(pattern).gsub("\\*\\*", ".*").gsub("\\*", "[^/]*")
|
|
131
|
+
Regexp.new("\\A#{expression}\\z").match?(value)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def default_decision(request)
|
|
135
|
+
return Decision.new(action: :deny, reason: "read-only mode") if @mode == "read-only" && risky?(request)
|
|
136
|
+
return Decision.new(action: :deny, reason: "deny-by-default mode") if @mode == "deny-by-default" && risky?(request)
|
|
137
|
+
|
|
138
|
+
if file_change?(request) && outside_write_scopes?(request)
|
|
139
|
+
return Decision.new(action: :deny, reason: "path outside write scopes")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
if @mode == "workspace-write" && file_change?(request)
|
|
143
|
+
return Decision.new(action: :allow, reason: "workspace write mode")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
return Decision.new(action: :allow, reason: "read-only tool") unless risky?(request)
|
|
147
|
+
|
|
148
|
+
Decision.new(action: :ask, reason: "#{request.fetch("tool")} requires approval")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def file_change?(request)
|
|
152
|
+
FILE_CHANGE_TOOLS.include?(request.fetch("tool"))
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def network?(request)
|
|
156
|
+
NETWORK_TOOLS.include?(request.fetch("tool")) || request["source"] == "mcp"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def risky?(request)
|
|
160
|
+
file_change?(request) || request.fetch("tool") == "run_shell_command" || network?(request)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def outside_write_scopes?(request)
|
|
164
|
+
return false if @write_scopes.nil?
|
|
165
|
+
|
|
166
|
+
path = request["path"]
|
|
167
|
+
path.nil? || !@write_scopes.any? { |scope| glob_match?(scope, path) }
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|