ask-app-server 0.4.30 → 0.5.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/CHANGELOG.md +62 -0
- data/README.md +9 -1
- data/lib/ask/app_server/agent_adapter.rb +57 -18
- data/lib/ask/app_server/event_translator.rb +8 -2
- data/lib/ask/app_server/permission_handler.rb +112 -36
- data/lib/ask/app_server/project_permission_grants.rb +152 -0
- data/lib/ask/app_server/server.rb +29 -5
- data/lib/ask/app_server/session_manager.rb +81 -12
- data/lib/ask/app_server/session_store.rb +8 -1
- data/lib/ask/app_server/version.rb +1 -1
- data/lib/ask-app-server.rb +1 -0
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '09c21d6313a0af92e78bed933837e2c292ad657deb958edc8e463e4cca52a4a1'
|
|
4
|
+
data.tar.gz: d6151661ac80585af0d25bed6363251b3a0a4db877308c50bd47d8260f628a2d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 674090973396a7cde266772e797f416a9d6533c99ecbbcb3690b209e9eecf38e141cb85ac65997a910c3ada517eab0319fd46f554dbf33515d76e7564b163c01
|
|
7
|
+
data.tar.gz: d8684d423ff60cf2e305d6f3b5ab38d3bec72dd8355943bbf53ee864ca7657a0c50915404d83ae848c593c01f3a71074e306b06325c47c486239c70ab9b77f0c
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,68 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.5.0] - 2026-09-23
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `session/resume` accepts optional workspace context — the same nested
|
|
10
|
+
shape `session/create` takes (`workspace.workspacePath`, or a top-level
|
|
11
|
+
`workspacePath`). It is passed to the manager for verification only; the
|
|
12
|
+
resume result shape is unchanged.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- `Ask::AppServer::PermissionHandler` now delegates decisions to the shared
|
|
17
|
+
`ask-permissions` policy (`PermissionRules` + `ApprovalQueue` +
|
|
18
|
+
`ApprovalPolicy`). Blocked tools are encoded as `ask` rules, approvals
|
|
19
|
+
flow through the shared queue (protocol request ids are the queue action
|
|
20
|
+
ids), and timeout/cancel fail closed via queue reject. The blocking
|
|
21
|
+
protocol API (`before_tool_call`, `handle_response`, `cancel_all!`,
|
|
22
|
+
`pending_count`/`pending?`, `on_request`, synchronous condition wait)
|
|
23
|
+
is unchanged.
|
|
24
|
+
- `EmittingApprovalQueue` forwards approval scope and rejection feedback to
|
|
25
|
+
the resolved action. App-server `approval.updated` events now include
|
|
26
|
+
non-default `scope` and rejection `feedback` fields when present.
|
|
27
|
+
- App-server approval interactions advertise supported scopes (`once` and
|
|
28
|
+
`session`, plus `project` when workspace context is available), accept
|
|
29
|
+
`scope`/rejection `feedback` through JSON-RPC,
|
|
30
|
+
default omitted scope to `once`, and reject unsupported scopes as invalid
|
|
31
|
+
requests rather than silently downgrading them. With workspace context,
|
|
32
|
+
sessions also offer `project` scope, persisted in the configured state
|
|
33
|
+
provider under a hashed canonical workspace identity.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- **`session/resume` fails closed on project scope.** A durable resume used
|
|
38
|
+
to reattach the stored project grants while rebuilding the agent with no
|
|
39
|
+
workspace (`agent_dir`/tools `default_workdir` nil) — restoring an old
|
|
40
|
+
project's approvals for tools running from the host's cwd. Grants are now
|
|
41
|
+
attached (and `project` advertised as an approval scope, and tools pinned
|
|
42
|
+
to the workspace) only when the caller supplies a workspace path that
|
|
43
|
+
canonicalizes to the session's stored identity; absent or mismatched
|
|
44
|
+
context resumes with `once`/`session` scopes only and no pinned workdir.
|
|
45
|
+
The stored `workspaceId` remains a SHA-256 of the canonical path — raw
|
|
46
|
+
absolute paths are never written to persisted metadata — and is preserved
|
|
47
|
+
across resumes so a later verified resume can still restore project scope.
|
|
48
|
+
In-process resume (the live adapter) is unchanged.
|
|
49
|
+
- **Project permission grants no longer lose updates across processes.**
|
|
50
|
+
`grant`/`revoke` read-modify-write cycles were serialized only by a
|
|
51
|
+
local `Mutex`, so two app-server processes sharing one SQLite/Postgres/
|
|
52
|
+
Redis/MySQL backend could interleave and silently drop each other's
|
|
53
|
+
writes (e.g. a revoke that never persists). Mutations now hold the
|
|
54
|
+
`Ask::State::Adapter` contract's distributed lock
|
|
55
|
+
(`acquire_lock`/`release_lock` — token-safe on every shipped backend)
|
|
56
|
+
across the whole read-modify-write, retrying briefly under contention and
|
|
57
|
+
failing closed (raising) instead of writing outside the lock if it cannot
|
|
58
|
+
be acquired. Adapters without lock support keep working through the
|
|
59
|
+
documented single-process fallback (local mutex only); reads stay
|
|
60
|
+
lock-free and available while a mutation waits.
|
|
61
|
+
## [0.4.30] - 2026-09-23
|
|
62
|
+
|
|
63
|
+
### Changed
|
|
64
|
+
|
|
65
|
+
- Migrated `ApprovalQueue` to `ask-permissions`.
|
|
66
|
+
|
|
5
67
|
### Fixed
|
|
6
68
|
|
|
7
69
|
- **Durable session listings survive restart.** When `state_adapter:` is
|
data/README.md
CHANGED
|
@@ -78,7 +78,7 @@ From another process, send JSON-RPC requests:
|
|
|
78
78
|
| `initialize` | Handshake; negotiates `protocolVersion` and capabilities |
|
|
79
79
|
| `session/create` | Create a new agent session |
|
|
80
80
|
| `session/list` | List active sessions |
|
|
81
|
-
| `session/resume` | Resume an existing session |
|
|
81
|
+
| `session/resume` | Resume an existing session (optional workspace context, see below) |
|
|
82
82
|
| `session/subscribe` | Subscribe to the event stream (with replay snapshot) |
|
|
83
83
|
| `session/send` | Prompt an idle session or inject mid-run (`steered`/`queued`/`stale`) |
|
|
84
84
|
| `session/events` | Poll for events after a sequence number |
|
|
@@ -111,6 +111,14 @@ Every event is a canonical `{type, seq, payload}` envelope, delivered as a
|
|
|
111
111
|
The full vocabulary, payload shapes, method specs, and versioning live in
|
|
112
112
|
the ask-session-protocol gem (JSON Schema artifact included).
|
|
113
113
|
|
|
114
|
+
`session/resume` takes an optional workspace context — the same nested
|
|
115
|
+
`workspace.workspacePath` shape as `session/create`. It is verified against
|
|
116
|
+
the session's stored identity (a SHA-256 of the canonical path; raw paths
|
|
117
|
+
are never persisted): project-scoped approvals come back and tools are
|
|
118
|
+
pinned to the workspace only when the supplied path canonicalizes to that
|
|
119
|
+
identity. Absent or mismatched context fails closed — the session resumes
|
|
120
|
+
with `once`/`session` approval scopes only and no pinned workdir.
|
|
121
|
+
|
|
114
122
|
The event log itself is event-sourced in [ask-session](https://github.com/ask-rb/ask-session)'s
|
|
115
123
|
`Host`: replay (`session/events`, subscribe snapshots, cursor push) reads
|
|
116
124
|
the durable log rather than an in-memory buffer, and protocol translation
|
|
@@ -28,6 +28,10 @@ module Ask
|
|
|
28
28
|
# keeps Host seq and wire seq contiguous from 1.
|
|
29
29
|
HOST_OWNED_EVENT_TYPES = %w[session.created session.ended].freeze
|
|
30
30
|
|
|
31
|
+
# Approval scopes available without workspace context. A project scope
|
|
32
|
+
# is added only when the host supplies a workspace grant store.
|
|
33
|
+
BASE_APPROVAL_SCOPES = %i[once session].freeze
|
|
34
|
+
|
|
31
35
|
# The underlying ask-agent session.
|
|
32
36
|
attr_reader :session
|
|
33
37
|
|
|
@@ -64,7 +68,7 @@ module Ask
|
|
|
64
68
|
# Ask::Agent::Session.new (hooks, plan_mode, todos, ...)
|
|
65
69
|
def initialize(model:, tools: nil, system_prompt: nil, agent_dir: nil,
|
|
66
70
|
approval: :off, require_approval: nil, host: nil,
|
|
67
|
-
state_adapter: nil, created_at: nil, **session_opts)
|
|
71
|
+
state_adapter: nil, created_at: nil, project_grants: nil, **session_opts)
|
|
68
72
|
@model = model
|
|
69
73
|
@system_prompt = system_prompt
|
|
70
74
|
@tools = resolve_tools(tools)
|
|
@@ -72,6 +76,7 @@ module Ask
|
|
|
72
76
|
@approval = approval
|
|
73
77
|
@require_approval = require_approval
|
|
74
78
|
@agent_dir = agent_dir
|
|
79
|
+
@project_grants = project_grants
|
|
75
80
|
@host = host || build_durable_host(state_adapter)
|
|
76
81
|
@durable_record = false
|
|
77
82
|
# The session's workspace is the tools' home: bash commands
|
|
@@ -191,6 +196,12 @@ module Ask
|
|
|
191
196
|
!@running
|
|
192
197
|
end
|
|
193
198
|
|
|
199
|
+
def allowed_approval_scopes
|
|
200
|
+
scopes = BASE_APPROVAL_SCOPES.dup
|
|
201
|
+
scopes << :project if @project_grants
|
|
202
|
+
scopes.map(&:to_s)
|
|
203
|
+
end
|
|
204
|
+
|
|
194
205
|
# ── Interactions (approvals) ───────────────────────────────────────
|
|
195
206
|
|
|
196
207
|
# Pending approval interactions, as canonical Interaction objects.
|
|
@@ -203,6 +214,7 @@ module Ask
|
|
|
203
214
|
payload["args"] = action.args if action.args
|
|
204
215
|
payload["message"] = action.message if action.message
|
|
205
216
|
payload["autoApprovable"] = action.auto_approvable unless action.auto_approvable.nil?
|
|
217
|
+
payload["allowedScopes"] = allowed_approval_scopes
|
|
206
218
|
Ask::SessionProtocol::Interactions.interaction(
|
|
207
219
|
id: "act_#{action.id}", kind: "approval", status: "pending", payload: payload
|
|
208
220
|
)
|
|
@@ -210,33 +222,41 @@ module Ask
|
|
|
210
222
|
end
|
|
211
223
|
|
|
212
224
|
# Approve a pending approval interaction by canonical id ("act_N").
|
|
225
|
+
# @param scope [Symbol, String] :once (default), :session, or :project
|
|
226
|
+
# when a workspace-scoped grant store is available.
|
|
213
227
|
# @return [Boolean] whether an action was approved
|
|
214
|
-
def approve_interaction(interaction_id)
|
|
215
|
-
|
|
228
|
+
def approve_interaction(interaction_id, scope: :once)
|
|
229
|
+
normalized = normalize_approval_scope!(scope)
|
|
230
|
+
apply_interaction(interaction_id) { |queue, id| queue.approve(id, scope: normalized) }
|
|
216
231
|
end
|
|
217
232
|
|
|
218
233
|
# Reject a pending approval interaction by canonical id ("act_N").
|
|
234
|
+
# @param feedback [String, nil] client-supplied denial reason,
|
|
235
|
+
# retained on the action and surfaced in approval.updated.
|
|
219
236
|
# @return [Boolean] whether an action was rejected
|
|
220
|
-
def reject_interaction(interaction_id)
|
|
221
|
-
apply_interaction(interaction_id) { |queue, id| queue.reject(id) }
|
|
237
|
+
def reject_interaction(interaction_id, feedback: nil)
|
|
238
|
+
apply_interaction(interaction_id) { |queue, id| queue.reject(id, feedback: feedback) }
|
|
222
239
|
end
|
|
223
240
|
|
|
224
241
|
# Approve every pending approval interaction.
|
|
242
|
+
# @param scope [Symbol, String] :once (default) or :session
|
|
225
243
|
# @return [Integer] number approved
|
|
226
|
-
def approve_all_interactions
|
|
244
|
+
def approve_all_interactions(scope: :once)
|
|
245
|
+
normalized = normalize_approval_scope!(scope)
|
|
227
246
|
queue = @session&.approval_queue
|
|
228
247
|
return 0 unless queue
|
|
229
248
|
|
|
230
|
-
queue.approve_all.size
|
|
249
|
+
queue.approve_all(scope: normalized).size
|
|
231
250
|
end
|
|
232
251
|
|
|
233
252
|
# Reject every pending approval interaction.
|
|
253
|
+
# @param feedback [String, nil] denial reason applied to each
|
|
234
254
|
# @return [Integer] number rejected
|
|
235
|
-
def reject_all_interactions
|
|
255
|
+
def reject_all_interactions(feedback: nil)
|
|
236
256
|
queue = @session&.approval_queue
|
|
237
257
|
return 0 unless queue
|
|
238
258
|
|
|
239
|
-
queue.reject_all.size
|
|
259
|
+
queue.reject_all(feedback: feedback).size
|
|
240
260
|
end
|
|
241
261
|
|
|
242
262
|
# ── Plan mode ───────────────────────────────────────────────────────
|
|
@@ -463,12 +483,14 @@ module Ask
|
|
|
463
483
|
return nil if @approval == :off
|
|
464
484
|
|
|
465
485
|
queue = EmittingApprovalQueue.new(
|
|
466
|
-
on_submit: ->(action) { @translator.approval_required(action) },
|
|
486
|
+
on_submit: ->(action) { @translator.approval_required(action, allowed_scopes: allowed_approval_scopes) },
|
|
467
487
|
on_status: ->(action) { @translator.approval_updated(action) }
|
|
468
488
|
)
|
|
469
|
-
|
|
489
|
+
opts = { queue: queue }
|
|
490
|
+
opts[:project_grants] = @project_grants if @project_grants
|
|
491
|
+
return opts if @approval == :auto
|
|
470
492
|
|
|
471
|
-
|
|
493
|
+
opts.merge(require_approval: @require_approval)
|
|
472
494
|
end
|
|
473
495
|
|
|
474
496
|
def apply_interaction(interaction_id)
|
|
@@ -481,6 +503,23 @@ module Ask
|
|
|
481
503
|
yield(queue, id.to_i).any?
|
|
482
504
|
end
|
|
483
505
|
|
|
506
|
+
# Normalize and validate an approval scope. Omitted scope defaults
|
|
507
|
+
# to :once (backward compatible). Anything outside
|
|
508
|
+
# Project scope is available only when the host supplied a project
|
|
509
|
+
# grants collaborator; unsupported scope raises InvalidRequest.
|
|
510
|
+
def normalize_approval_scope!(scope)
|
|
511
|
+
normalized = if scope.nil?
|
|
512
|
+
:once
|
|
513
|
+
elsif scope.is_a?(String) || scope.is_a?(Symbol)
|
|
514
|
+
scope.to_sym
|
|
515
|
+
end
|
|
516
|
+
unless allowed_approval_scopes.include?(normalized.to_s)
|
|
517
|
+
raise InvalidRequest,
|
|
518
|
+
"Unsupported approval scope '#{scope}' (supported: #{allowed_approval_scopes.join(', ')})"
|
|
519
|
+
end
|
|
520
|
+
normalized
|
|
521
|
+
end
|
|
522
|
+
|
|
484
523
|
def start_run(content)
|
|
485
524
|
@running_mutex.synchronize do
|
|
486
525
|
@abort_requested = false
|
|
@@ -624,15 +663,15 @@ module Ask
|
|
|
624
663
|
|
|
625
664
|
private
|
|
626
665
|
|
|
627
|
-
def apply(action)
|
|
628
|
-
result = super
|
|
629
|
-
@on_status&.call(
|
|
666
|
+
def apply(action, scope: :once)
|
|
667
|
+
result = super(action, scope: scope)
|
|
668
|
+
@on_status&.call(result)
|
|
630
669
|
result
|
|
631
670
|
end
|
|
632
671
|
|
|
633
|
-
def reject_action(action)
|
|
634
|
-
result = super
|
|
635
|
-
@on_status&.call(
|
|
672
|
+
def reject_action(action, feedback: nil)
|
|
673
|
+
result = super(action, feedback: feedback)
|
|
674
|
+
@on_status&.call(result)
|
|
636
675
|
result
|
|
637
676
|
end
|
|
638
677
|
end
|
|
@@ -96,11 +96,12 @@ module Ask
|
|
|
96
96
|
# An approval action was queued: emit approval.required.
|
|
97
97
|
#
|
|
98
98
|
# @param action [Ask::Permissions::ApprovalQueue::Action]
|
|
99
|
-
def approval_required(action)
|
|
99
|
+
def approval_required(action, allowed_scopes: %w[once session])
|
|
100
100
|
payload = { "toolName" => action.tool_name.to_s }
|
|
101
101
|
payload["args"] = action.args if action.args
|
|
102
102
|
payload["message"] = action.message if action.message
|
|
103
103
|
payload["autoApprovable"] = action.auto_approvable unless action.auto_approvable.nil?
|
|
104
|
+
payload["allowedScopes"] = allowed_scopes
|
|
104
105
|
emit("approval.required", payload.merge("id" => "act_#{action.id}"))
|
|
105
106
|
end
|
|
106
107
|
|
|
@@ -111,7 +112,12 @@ module Ask
|
|
|
111
112
|
status = action.status.to_s
|
|
112
113
|
return unless %w[approved rejected].include?(status)
|
|
113
114
|
|
|
114
|
-
|
|
115
|
+
payload = { "id" => "act_#{action.id}", "status" => status }
|
|
116
|
+
if status == "approved" && action.resolution_scope && action.resolution_scope != :once
|
|
117
|
+
payload["scope"] = action.resolution_scope.to_s
|
|
118
|
+
end
|
|
119
|
+
payload["feedback"] = action.feedback if status == "rejected" && action.feedback
|
|
120
|
+
emit("approval.updated", payload)
|
|
115
121
|
end
|
|
116
122
|
|
|
117
123
|
# A session was created: emit session.created.
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "ask/permissions"
|
|
4
4
|
|
|
5
5
|
module Ask
|
|
6
6
|
module AppServer
|
|
7
7
|
# Protocol-aware permission handler that integrates with ask-agent's
|
|
8
8
|
# before_tool_call hook system.
|
|
9
9
|
#
|
|
10
|
-
#
|
|
10
|
+
# Decision logic is delegated to the shared ask-permissions policy
|
|
11
|
+
# (PermissionRules + ApprovalQueue + ApprovalPolicy). This handler
|
|
12
|
+
# preserves its blocking protocol API on top:
|
|
11
13
|
# 1. Sends an `interaction/requestPermission` protocol message to the client
|
|
12
14
|
# 2. Blocks the tool thread until the client responds (or timeout)
|
|
13
15
|
# 3. Returns { action: :proceed } if approved, { action: :block } if denied
|
|
@@ -28,7 +30,7 @@ module Ask
|
|
|
28
30
|
# Default timeout in seconds.
|
|
29
31
|
DEFAULT_TIMEOUT = 300
|
|
30
32
|
|
|
31
|
-
attr_reader :mode
|
|
33
|
+
attr_reader :mode, :queue, :rules, :policy
|
|
32
34
|
|
|
33
35
|
# @param mode [Symbol] :on_request (ask for dangerous tools) or :never (allow all)
|
|
34
36
|
# @param blocked_tools [Array<Symbol>] list of tool names that require permission
|
|
@@ -37,10 +39,24 @@ module Ask
|
|
|
37
39
|
@mode = mode
|
|
38
40
|
@blocked_tools = (blocked_tools || DEFAULT_BLOCKED_TOOLS).map(&:to_sym)
|
|
39
41
|
@timeout = timeout
|
|
40
|
-
@
|
|
42
|
+
@waiters = {}
|
|
41
43
|
@sender = nil
|
|
42
44
|
@mutex = Mutex.new
|
|
43
45
|
@logger = Logger.new($stdout, level: ENV["DEBUG"] ? Logger::DEBUG : Logger::WARN)
|
|
46
|
+
|
|
47
|
+
@rules = Ask::Permissions::PermissionRules.new
|
|
48
|
+
@blocked_tools.each { |tool| @rules.ask(tool.to_s) }
|
|
49
|
+
|
|
50
|
+
@queue = Ask::Permissions::ApprovalQueue.new(
|
|
51
|
+
on_submit: ->(action) { @sender&.call(action.id.to_s, action.tool_name.to_s, action.args) }
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@policy = Ask::Permissions::ApprovalPolicy.new(
|
|
55
|
+
queue: @queue,
|
|
56
|
+
rules: @rules,
|
|
57
|
+
require_approval: (@mode != :never),
|
|
58
|
+
tools: @blocked_tools.map(&:to_s)
|
|
59
|
+
)
|
|
44
60
|
end
|
|
45
61
|
|
|
46
62
|
# Register a callback for sending the protocol message.
|
|
@@ -51,11 +67,27 @@ module Ask
|
|
|
51
67
|
|
|
52
68
|
# Hook interface for Ask::Agent::Session's before_tool_call chain.
|
|
53
69
|
# Returns { action: :proceed } or { action: :block, reason: "..." }.
|
|
54
|
-
def before_tool_call(tool_call,
|
|
55
|
-
return { action: :proceed } unless @blocked_tools.include?(tool_call.name.to_sym)
|
|
70
|
+
def before_tool_call(tool_call, context = nil)
|
|
56
71
|
return { action: :proceed } if @mode == :never
|
|
57
72
|
|
|
58
|
-
|
|
73
|
+
result = @policy.before_tool_call(tool_call, context)
|
|
74
|
+
|
|
75
|
+
case result[:action]
|
|
76
|
+
when :proceed
|
|
77
|
+
{ action: :proceed }
|
|
78
|
+
when :block
|
|
79
|
+
{ action: :block, reason: result[:reason] || "Permission denied" }
|
|
80
|
+
when :pending
|
|
81
|
+
action_id = result[:action_id]
|
|
82
|
+
return { action: :block, reason: "Permission error: missing action id" } if action_id.nil?
|
|
83
|
+
|
|
84
|
+
wait_for_approval(action_id)
|
|
85
|
+
else
|
|
86
|
+
{ action: :block, reason: "Permission error: unknown policy decision" }
|
|
87
|
+
end
|
|
88
|
+
rescue => e
|
|
89
|
+
@logger.debug("Permission error: #{e.message}")
|
|
90
|
+
{ action: :block, reason: "Permission error: #{e.message}" }
|
|
59
91
|
end
|
|
60
92
|
|
|
61
93
|
# Called by the server when the client responds to a permission request.
|
|
@@ -64,14 +96,27 @@ module Ask
|
|
|
64
96
|
# @param decision [String] "approve" or "deny"
|
|
65
97
|
# @param reason [String, nil] optional reason from the client
|
|
66
98
|
def handle_response(request_id, decision, reason: nil)
|
|
99
|
+
action_id = parse_action_id(request_id)
|
|
100
|
+
return false if action_id.nil?
|
|
101
|
+
|
|
67
102
|
@mutex.synchronize do
|
|
68
|
-
|
|
69
|
-
|
|
103
|
+
action = @queue[action_id]
|
|
104
|
+
entry = @waiters[action_id]
|
|
105
|
+
return false if action.nil? && entry.nil?
|
|
106
|
+
return false if !action.nil? && !action.pending? && entry.nil?
|
|
107
|
+
|
|
108
|
+
if decision.to_s == "approve"
|
|
109
|
+
@queue.approve(action_id)
|
|
110
|
+
else
|
|
111
|
+
@queue.reject(action_id)
|
|
112
|
+
end
|
|
70
113
|
|
|
71
|
-
entry
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
114
|
+
if entry
|
|
115
|
+
entry[:responded] = true
|
|
116
|
+
entry[:approved] = (decision.to_s == "approve")
|
|
117
|
+
entry[:reason] = reason
|
|
118
|
+
entry[:condition].signal
|
|
119
|
+
end
|
|
75
120
|
true
|
|
76
121
|
end
|
|
77
122
|
end
|
|
@@ -79,35 +124,54 @@ module Ask
|
|
|
79
124
|
# Cancel all pending permission requests (e.g., on session shutdown).
|
|
80
125
|
def cancel_all!
|
|
81
126
|
@mutex.synchronize do
|
|
82
|
-
@
|
|
127
|
+
@waiters.each_value do |entry|
|
|
83
128
|
entry[:responded] = true
|
|
84
129
|
entry[:approved] = false
|
|
85
130
|
entry[:reason] = "Permission request cancelled"
|
|
86
131
|
entry[:condition].signal
|
|
87
132
|
end
|
|
88
|
-
@
|
|
133
|
+
@waiters.clear
|
|
89
134
|
end
|
|
135
|
+
@queue.reject_all
|
|
90
136
|
end
|
|
91
137
|
|
|
92
138
|
# Number of pending permission requests.
|
|
93
139
|
def pending_count
|
|
94
|
-
@
|
|
140
|
+
@queue.pending_actions.size
|
|
95
141
|
end
|
|
96
142
|
|
|
97
143
|
# Are there any pending permission requests?
|
|
98
144
|
def pending?
|
|
99
|
-
|
|
145
|
+
@queue.any_pending?
|
|
100
146
|
end
|
|
101
147
|
|
|
102
148
|
private
|
|
103
149
|
|
|
104
|
-
def
|
|
105
|
-
request_id
|
|
150
|
+
def parse_action_id(request_id)
|
|
151
|
+
return request_id if request_id.is_a?(Integer)
|
|
152
|
+
return nil if request_id.nil?
|
|
153
|
+
|
|
154
|
+
str = request_id.to_s
|
|
155
|
+
return nil unless str.match?(/\A\d+\z/)
|
|
156
|
+
|
|
157
|
+
str.to_i
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def wait_for_approval(action_id)
|
|
106
161
|
condition = ConditionVariable.new
|
|
107
162
|
|
|
108
163
|
@mutex.synchronize do
|
|
109
|
-
|
|
110
|
-
|
|
164
|
+
# If the response already landed before we registered, consume it.
|
|
165
|
+
action = @queue[action_id]
|
|
166
|
+
if action && action.approved?
|
|
167
|
+
@waiters.delete(action_id)
|
|
168
|
+
return { action: :proceed }
|
|
169
|
+
elsif action && action.rejected?
|
|
170
|
+
@waiters.delete(action_id)
|
|
171
|
+
return { action: :block, reason: "Permission denied" }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
@waiters[action_id] = {
|
|
111
175
|
condition: condition,
|
|
112
176
|
responded: false,
|
|
113
177
|
approved: false,
|
|
@@ -116,49 +180,61 @@ module Ask
|
|
|
116
180
|
}
|
|
117
181
|
end
|
|
118
182
|
|
|
119
|
-
@logger.debug("Requesting permission
|
|
183
|
+
@logger.debug("Requesting permission (#{action_id})")
|
|
120
184
|
|
|
121
|
-
# Send the permission request via the registered callback
|
|
122
|
-
@sender&.call(request_id, tool_call.name.to_s, tool_call.arguments)
|
|
123
|
-
|
|
124
|
-
# Block the tool thread until the client responds
|
|
125
|
-
response_reason = nil
|
|
126
185
|
was_approved = false
|
|
186
|
+
response_reason = nil
|
|
127
187
|
|
|
128
188
|
@mutex.synchronize do
|
|
129
189
|
deadline = Time.now + @timeout
|
|
130
190
|
|
|
131
191
|
loop do
|
|
132
|
-
entry = @
|
|
133
|
-
break unless entry
|
|
134
|
-
|
|
192
|
+
entry = @waiters[action_id]
|
|
193
|
+
break unless entry # cancelled
|
|
194
|
+
|
|
195
|
+
# Fast-path: queue already resolved (handles approve-before-wait race).
|
|
196
|
+
action = @queue[action_id]
|
|
197
|
+
if action && action.approved?
|
|
198
|
+
entry[:approved] = true
|
|
199
|
+
entry[:responded] = true
|
|
200
|
+
elsif action && action.rejected? && !entry[:responded]
|
|
201
|
+
entry[:responded] = true
|
|
202
|
+
entry[:approved] = false
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
break if entry[:responded]
|
|
135
206
|
|
|
136
207
|
remaining = deadline - Time.now
|
|
137
208
|
if remaining <= 0
|
|
138
|
-
@
|
|
139
|
-
@
|
|
209
|
+
@waiters.delete(action_id)
|
|
210
|
+
@queue.reject(action_id)
|
|
211
|
+
@logger.debug("Permission request #{action_id} timed out")
|
|
140
212
|
return { action: :block, reason: "Permission request timed out after #{@timeout}s" }
|
|
141
213
|
end
|
|
142
214
|
|
|
143
215
|
condition.wait(@mutex, remaining)
|
|
144
216
|
end
|
|
145
217
|
|
|
146
|
-
entry = @
|
|
218
|
+
entry = @waiters.delete(action_id)
|
|
147
219
|
if entry
|
|
148
220
|
was_approved = entry[:approved]
|
|
149
221
|
response_reason = entry[:reason]
|
|
222
|
+
else
|
|
223
|
+
# Cancelled via cancel_all! (entry cleared after signal).
|
|
224
|
+
return { action: :block, reason: "Permission request cancelled" }
|
|
150
225
|
end
|
|
151
226
|
end
|
|
152
227
|
|
|
153
228
|
if was_approved
|
|
154
|
-
@logger.debug("Permission granted for #{
|
|
229
|
+
@logger.debug("Permission granted for #{action_id}")
|
|
155
230
|
{ action: :proceed }
|
|
156
231
|
else
|
|
157
|
-
@logger.debug("Permission denied for #{
|
|
232
|
+
@logger.debug("Permission denied for #{action_id}: #{response_reason}")
|
|
158
233
|
{ action: :block, reason: response_reason || "Permission denied" }
|
|
159
234
|
end
|
|
160
235
|
rescue => e
|
|
161
|
-
@
|
|
236
|
+
@mutex.synchronize { @waiters.delete(action_id) } rescue nil
|
|
237
|
+
@queue.reject(action_id) rescue nil
|
|
162
238
|
@logger.debug("Permission error: #{e.message}")
|
|
163
239
|
{ action: :block, reason: "Permission error: #{e.message}" }
|
|
164
240
|
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module AppServer
|
|
7
|
+
# Persistent tool grants scoped to one canonical workspace identity.
|
|
8
|
+
# Storage is owned by the host and supplied through ask-state-providers.
|
|
9
|
+
#
|
|
10
|
+
# grant/revoke are read-modify-write cycles. A local Mutex only
|
|
11
|
+
# serializes callers inside one process; two app-server processes
|
|
12
|
+
# sharing a SQLite/Postgres/Redis/MySQL backend interleave their
|
|
13
|
+
# get→set cycles and lose each other's updates (a revoked grant
|
|
14
|
+
# silently surviving, or a grant never persisting). The
|
|
15
|
+
# Ask::State::Adapter contract's distributed lock
|
|
16
|
+
# (`acquire_lock`/`release_lock` — token-safe on every shipped
|
|
17
|
+
# backend) is therefore held across each mutation whenever the
|
|
18
|
+
# supplied adapter implements it.
|
|
19
|
+
#
|
|
20
|
+
# Adapters without lock support keep working: mutations fall back
|
|
21
|
+
# to the local Mutex alone, which is single-process safe only —
|
|
22
|
+
# cross-process deployments must use a contract-conforming state
|
|
23
|
+
# adapter. If the lock cannot be acquired within the timeout the
|
|
24
|
+
# mutation fails closed (raises) rather than writing outside the
|
|
25
|
+
# lock.
|
|
26
|
+
class ProjectPermissionGrants
|
|
27
|
+
SNAPSHOT_VERSION = 1
|
|
28
|
+
|
|
29
|
+
# How long one mutation may hold the state-provider lock. The
|
|
30
|
+
# critical section is a get plus a set — milliseconds; the TTL is
|
|
31
|
+
# only a stale-lock backstop.
|
|
32
|
+
LOCK_TTL = 10
|
|
33
|
+
|
|
34
|
+
# How long a contended mutation waits for the provider lock before
|
|
35
|
+
# failing closed.
|
|
36
|
+
LOCK_TIMEOUT = 2.0
|
|
37
|
+
|
|
38
|
+
# Pause between acquire attempts while contended.
|
|
39
|
+
LOCK_RETRY_INTERVAL = 0.005
|
|
40
|
+
|
|
41
|
+
attr_reader :project_id
|
|
42
|
+
|
|
43
|
+
def initialize(state:, project_id:, lock_timeout: LOCK_TIMEOUT)
|
|
44
|
+
normalized = project_id.to_s.strip
|
|
45
|
+
raise ArgumentError, "project_id must not be blank" if normalized.empty?
|
|
46
|
+
raise ArgumentError, "state must respond to get and set" unless state.respond_to?(:get) && state.respond_to?(:set)
|
|
47
|
+
|
|
48
|
+
@state = state
|
|
49
|
+
@project_id = normalized
|
|
50
|
+
@key = "ask-app-server:project-permission-grants:#{Digest::SHA256.hexdigest(normalized)}"
|
|
51
|
+
@lock_key = "#{@key}:lock"
|
|
52
|
+
@lock_timeout = lock_timeout
|
|
53
|
+
@mutex = Mutex.new
|
|
54
|
+
@distributed_lock = state.respond_to?(:acquire_lock) && state.respond_to?(:release_lock)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def granted?(tool_name)
|
|
58
|
+
name = normalize_tool_name(tool_name)
|
|
59
|
+
read_tools.include?(name)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def grant(tool_name)
|
|
63
|
+
name = normalize_tool_name(tool_name)
|
|
64
|
+
with_mutation_lock do
|
|
65
|
+
tools = read_tools
|
|
66
|
+
next false if tools.include?(name)
|
|
67
|
+
|
|
68
|
+
write_tools(tools + [name])
|
|
69
|
+
true
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def revoke(tool_name)
|
|
74
|
+
name = normalize_tool_name(tool_name)
|
|
75
|
+
with_mutation_lock do
|
|
76
|
+
tools = read_tools
|
|
77
|
+
next false unless tools.include?(name)
|
|
78
|
+
|
|
79
|
+
write_tools(tools - [name])
|
|
80
|
+
true
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def snapshot
|
|
85
|
+
{ version: SNAPSHOT_VERSION, granted_tools: read_tools }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
# Run one read-modify-write mutation under exclusive access. With
|
|
91
|
+
# a contract-conforming adapter that is the state provider's
|
|
92
|
+
# distributed lock (shared by every process on the backend);
|
|
93
|
+
# otherwise the local Mutex (single-process only — see the class
|
|
94
|
+
# comment). Returns the block's value.
|
|
95
|
+
def with_mutation_lock
|
|
96
|
+
return @mutex.synchronize { yield } unless @distributed_lock
|
|
97
|
+
|
|
98
|
+
lock = acquire_state_lock!
|
|
99
|
+
begin
|
|
100
|
+
yield
|
|
101
|
+
ensure
|
|
102
|
+
@state.release_lock(@lock_key, lock)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Acquire the provider lock, retrying while a peer holds it.
|
|
107
|
+
# Raises (fail closed) instead of proceeding unlocked when the
|
|
108
|
+
# contention outlasts the timeout.
|
|
109
|
+
def acquire_state_lock!
|
|
110
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @lock_timeout
|
|
111
|
+
loop do
|
|
112
|
+
lock = @state.acquire_lock(@lock_key, ttl: LOCK_TTL)
|
|
113
|
+
return lock if lock
|
|
114
|
+
|
|
115
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
116
|
+
raise Ask::AppServer::Error,
|
|
117
|
+
"Timed out acquiring the project permission grant lock after #{@lock_timeout}s"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
sleep LOCK_RETRY_INTERVAL
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def normalize_tool_name(tool_name)
|
|
125
|
+
name = tool_name.to_s.strip
|
|
126
|
+
raise ArgumentError, "tool_name must not be blank" if name.empty?
|
|
127
|
+
|
|
128
|
+
name
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Reads are a single atomic get on every backend — they stay
|
|
132
|
+
# lock-free (and available even when a mutation is failing closed
|
|
133
|
+
# on lock contention).
|
|
134
|
+
def read_tools
|
|
135
|
+
stored = @state.get(@key)
|
|
136
|
+
return [] if stored.nil?
|
|
137
|
+
|
|
138
|
+
version = stored[:version] || stored["version"] if stored.is_a?(Hash)
|
|
139
|
+
tools = stored[:granted_tools] || stored["granted_tools"] if stored.is_a?(Hash)
|
|
140
|
+
unless version == SNAPSHOT_VERSION && tools.is_a?(Array) && tools.all? { |name| name.is_a?(String) && !name.empty? }
|
|
141
|
+
raise Ask::AppServer::Error, "Stored project permission grants are malformed"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
tools.uniq
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def write_tools(tools)
|
|
148
|
+
@state.set(@key, { version: SNAPSHOT_VERSION, granted_tools: tools.sort })
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -260,6 +260,15 @@ module Ask
|
|
|
260
260
|
10_000 + @outgoing_id
|
|
261
261
|
end
|
|
262
262
|
|
|
263
|
+
# Optional workspace context on session/resume: the nested shape
|
|
264
|
+
# session/create accepts, plus a top-level workspacePath. Absent
|
|
265
|
+
# (nil) means no proof — the manager fails closed.
|
|
266
|
+
def resume_workspace_path(params)
|
|
267
|
+
workspace = params["workspace"] || params[:workspace]
|
|
268
|
+
path = workspace.is_a?(Hash) ? (workspace["workspacePath"] || workspace[:workspacePath]) : nil
|
|
269
|
+
path || params["workspacePath"] || params[:workspacePath]
|
|
270
|
+
end
|
|
271
|
+
|
|
263
272
|
def reader_loop(connection)
|
|
264
273
|
while @running
|
|
265
274
|
line = connection.read_line
|
|
@@ -360,11 +369,22 @@ module Ask
|
|
|
360
369
|
# otherwise a durable restart resume: rebuild a fresh agent
|
|
361
370
|
# session from the Host (restored from its snapshot when one
|
|
362
371
|
# exists) and attach it to the live registry.
|
|
372
|
+
#
|
|
373
|
+
# Optional workspace context: the same nested shape
|
|
374
|
+
# session/create accepts (`workspace.workspacePath`, or a
|
|
375
|
+
# top-level `workspacePath`). It is passed through for
|
|
376
|
+
# verification only — resume fails closed (no project approval
|
|
377
|
+
# scope, tools unpinned from any workspace) when it is absent
|
|
378
|
+
# or does not canonicalize to the session's stored (hashed)
|
|
379
|
+
# workspace identity. See SessionManager#resume_session.
|
|
363
380
|
handler("session/resume") do |params, _id|
|
|
364
381
|
session_id = params["sessionId"] || params[:sessionId]
|
|
365
382
|
raise InvalidRequest, "sessionId is required" unless session_id
|
|
366
383
|
|
|
367
|
-
adapter = @session_manager.resume_session(
|
|
384
|
+
adapter = @session_manager.resume_session(
|
|
385
|
+
session_id,
|
|
386
|
+
workspace_path: resume_workspace_path(params)
|
|
387
|
+
)
|
|
368
388
|
|
|
369
389
|
{
|
|
370
390
|
sessionId: session_id,
|
|
@@ -488,9 +508,10 @@ module Ask
|
|
|
488
508
|
handler("interaction/approve") do |params, _id|
|
|
489
509
|
session_id = params["sessionId"] || params[:sessionId]
|
|
490
510
|
interaction_id = params["interactionId"] || params[:interactionId]
|
|
511
|
+
scope = params["scope"] || params[:scope] || "once"
|
|
491
512
|
raise InvalidRequest, "sessionId and interactionId are required" unless session_id && interaction_id
|
|
492
513
|
|
|
493
|
-
approved = @session_manager.approve_interaction(session_id, interaction_id)
|
|
514
|
+
approved = @session_manager.approve_interaction(session_id, interaction_id, scope: scope)
|
|
494
515
|
raise Ask::AppServer::InteractionNotFound, "Interaction #{interaction_id} not found" unless approved
|
|
495
516
|
|
|
496
517
|
{ approved: true, interactionId: interaction_id }
|
|
@@ -500,9 +521,10 @@ module Ask
|
|
|
500
521
|
handler("interaction/reject") do |params, _id|
|
|
501
522
|
session_id = params["sessionId"] || params[:sessionId]
|
|
502
523
|
interaction_id = params["interactionId"] || params[:interactionId]
|
|
524
|
+
feedback = params["feedback"] || params[:feedback]
|
|
503
525
|
raise InvalidRequest, "sessionId and interactionId are required" unless session_id && interaction_id
|
|
504
526
|
|
|
505
|
-
rejected = @session_manager.reject_interaction(session_id, interaction_id)
|
|
527
|
+
rejected = @session_manager.reject_interaction(session_id, interaction_id, feedback: feedback)
|
|
506
528
|
raise Ask::AppServer::InteractionNotFound, "Interaction #{interaction_id} not found" unless rejected
|
|
507
529
|
|
|
508
530
|
{ rejected: true, interactionId: interaction_id }
|
|
@@ -511,17 +533,19 @@ module Ask
|
|
|
511
533
|
# Interaction: approve all pending
|
|
512
534
|
handler("interaction/approve-all") do |params, _id|
|
|
513
535
|
session_id = params["sessionId"] || params[:sessionId]
|
|
536
|
+
scope = params["scope"] || params[:scope] || "once"
|
|
514
537
|
raise InvalidRequest, "sessionId is required" unless session_id
|
|
515
538
|
|
|
516
|
-
{ approved: @session_manager.approve_all_interactions(session_id) }
|
|
539
|
+
{ approved: @session_manager.approve_all_interactions(session_id, scope: scope) }
|
|
517
540
|
end
|
|
518
541
|
|
|
519
542
|
# Interaction: reject all pending
|
|
520
543
|
handler("interaction/reject-all") do |params, _id|
|
|
521
544
|
session_id = params["sessionId"] || params[:sessionId]
|
|
545
|
+
feedback = params["feedback"] || params[:feedback]
|
|
522
546
|
raise InvalidRequest, "sessionId is required" unless session_id
|
|
523
547
|
|
|
524
|
-
{ rejected: @session_manager.reject_all_interactions(session_id) }
|
|
548
|
+
{ rejected: @session_manager.reject_all_interactions(session_id, feedback: feedback) }
|
|
525
549
|
end
|
|
526
550
|
|
|
527
551
|
# Interaction: respond to a user_input interaction (elicitation).
|
|
@@ -33,8 +33,12 @@ module Ask
|
|
|
33
33
|
attr_reader :permission_timeout
|
|
34
34
|
|
|
35
35
|
def initialize(store: nil, host: nil, state_adapter: nil, permission_mode: :on_request, blocked_tools: nil, permission_timeout: 300)
|
|
36
|
+
@state_adapter = state_adapter
|
|
37
|
+
@project_grant_state = state_adapter || Ask::State::Memory.new
|
|
36
38
|
@store = store || SessionStore.new(state: state_adapter)
|
|
37
39
|
@host = host || build_host(state_adapter)
|
|
40
|
+
@project_grant_stores = {}
|
|
41
|
+
@project_grant_mutex = Mutex.new
|
|
38
42
|
@permission_mode = permission_mode
|
|
39
43
|
@blocked_tools = (blocked_tools || DEFAULT_REQUIRE_APPROVAL).map(&:to_s)
|
|
40
44
|
@permission_timeout = permission_timeout
|
|
@@ -69,6 +73,7 @@ module Ask
|
|
|
69
73
|
# falls back to the manager's permission_mode
|
|
70
74
|
def create_session(workspace_path: nil, mode: nil, model: nil, tools: nil, system_prompt: nil)
|
|
71
75
|
approval_opts, plan_mode = resolve_approval(mode)
|
|
76
|
+
workspace_id = workspace_identity(workspace_path)
|
|
72
77
|
|
|
73
78
|
# A provider-qualified model must stay resolvable: the agent's
|
|
74
79
|
# model catalog looks up bare ids, so the prefixed model is
|
|
@@ -86,12 +91,13 @@ module Ask
|
|
|
86
91
|
agent_dir: workspace_path,
|
|
87
92
|
approval: approval_opts[:mode],
|
|
88
93
|
require_approval: approval_opts[:require_approval],
|
|
94
|
+
project_grants: project_grants_for(workspace_id),
|
|
89
95
|
plan_mode: plan_mode,
|
|
90
96
|
host: @host
|
|
91
97
|
)
|
|
92
98
|
|
|
93
99
|
session_id = adapter.start_session
|
|
94
|
-
@store.add(session_id, adapter)
|
|
100
|
+
@store.add(session_id, adapter, workspace_id: workspace_id)
|
|
95
101
|
|
|
96
102
|
# Attach observers after registration so they see a settled store
|
|
97
103
|
# (e.g. the pane reporter's session-count metadata), then replay
|
|
@@ -115,11 +121,25 @@ module Ask
|
|
|
115
121
|
# restarts: the rebuilt adapter uses this manager's configured
|
|
116
122
|
# defaults.
|
|
117
123
|
#
|
|
124
|
+
# Workspace context fails closed. The stored +workspaceId+ is a
|
|
125
|
+
# hash of the canonical workspace path, never the path itself, so
|
|
126
|
+
# the caller must prove the context by supplying +workspace_path+:
|
|
127
|
+
# project grants are attached (and `project` advertised as an
|
|
128
|
+
# approval scope, and tools pinned to the workspace via
|
|
129
|
+
# default_workdir) only when the supplied path canonicalizes to
|
|
130
|
+
# that stored identity. Absent or mismatched context resumes the
|
|
131
|
+
# session with no project scope — grants never reach tools that
|
|
132
|
+
# would run from an unverified working directory. The stored
|
|
133
|
+
# identity itself is preserved either way (still a hash), so a
|
|
134
|
+
# later verified resume can restore it.
|
|
135
|
+
#
|
|
136
|
+
# @param session_id [String]
|
|
137
|
+
# @param workspace_path [String, nil] the caller's workspace context
|
|
118
138
|
# @return [AgentAdapter]
|
|
119
139
|
# @raise [SessionNotFound] when neither the live registry nor the
|
|
120
140
|
# durable Host knows the session, or its durable record is
|
|
121
141
|
# terminal (closed/aborted)
|
|
122
|
-
def resume_session(session_id)
|
|
142
|
+
def resume_session(session_id, workspace_path: nil)
|
|
123
143
|
adapter = @store.get(session_id)
|
|
124
144
|
return adapter if adapter
|
|
125
145
|
|
|
@@ -129,25 +149,56 @@ module Ask
|
|
|
129
149
|
raise Ask::AppServer::SessionNotFound, "Session #{session_id} is not active"
|
|
130
150
|
end
|
|
131
151
|
|
|
152
|
+
stored_metadata = @store.metadata(session_id) || {}
|
|
153
|
+
workspace_id = stored_metadata[:workspaceId] || stored_metadata["workspaceId"]
|
|
154
|
+
workspace_path = verified_resume_workspace(workspace_id, workspace_path)
|
|
155
|
+
project_grants = project_grants_for(workspace_id) if workspace_path
|
|
132
156
|
approval_opts, plan_mode = resolve_approval(nil)
|
|
133
157
|
adapter = AgentAdapter.new(
|
|
134
158
|
model: resolve_agent_model(nil),
|
|
135
159
|
tools: DEFAULT_TOOLS,
|
|
136
|
-
system_prompt: build_default_system_prompt(
|
|
160
|
+
system_prompt: build_default_system_prompt(workspace_path),
|
|
161
|
+
agent_dir: workspace_path,
|
|
137
162
|
approval: approval_opts[:mode],
|
|
138
163
|
require_approval: approval_opts[:require_approval],
|
|
164
|
+
project_grants: project_grants,
|
|
139
165
|
plan_mode: plan_mode,
|
|
140
166
|
host: @host,
|
|
141
167
|
created_at: record.created_at
|
|
142
168
|
)
|
|
143
169
|
adapter.resume_from_host(session_id)
|
|
144
|
-
@store.add(session_id, adapter)
|
|
170
|
+
@store.add(session_id, adapter, workspace_id: workspace_id)
|
|
145
171
|
|
|
146
172
|
adapter.on_event { |event| notify_session_event(adapter.session_id, event) }
|
|
147
173
|
@logger.info("Resumed session #{session_id} from the durable host")
|
|
148
174
|
adapter
|
|
149
175
|
end
|
|
150
176
|
|
|
177
|
+
def project_grants_for(workspace_id)
|
|
178
|
+
return nil unless workspace_id
|
|
179
|
+
|
|
180
|
+
@project_grant_mutex.synchronize do
|
|
181
|
+
@project_grant_stores[workspace_id] ||= ProjectPermissionGrants.new(
|
|
182
|
+
state: @project_grant_state,
|
|
183
|
+
project_id: workspace_id
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def workspace_identity(path)
|
|
189
|
+
canonical = canonical_workspace_path(path)
|
|
190
|
+
canonical && "workspace:#{Digest::SHA256.hexdigest(canonical)}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def canonical_workspace_path(path)
|
|
194
|
+
return nil if path.nil? || path.to_s.strip.empty?
|
|
195
|
+
|
|
196
|
+
expanded = File.expand_path(path.to_s)
|
|
197
|
+
File.realpath(expanded)
|
|
198
|
+
rescue SystemCallError
|
|
199
|
+
expanded
|
|
200
|
+
end
|
|
201
|
+
|
|
151
202
|
# Remove a session.
|
|
152
203
|
def destroy_session(session_id)
|
|
153
204
|
adapter = @store.get(session_id)
|
|
@@ -202,39 +253,43 @@ module Ask
|
|
|
202
253
|
end
|
|
203
254
|
|
|
204
255
|
# Approve a pending approval interaction by canonical id.
|
|
256
|
+
# @param scope [Symbol, String] :once (default) or :session
|
|
205
257
|
# @return [Boolean]
|
|
206
|
-
def approve_interaction(session_id, interaction_id)
|
|
258
|
+
def approve_interaction(session_id, interaction_id, scope: :once)
|
|
207
259
|
adapter = @store.get(session_id)
|
|
208
260
|
raise Ask::AppServer::SessionNotFound, "Session #{session_id} not found" unless adapter
|
|
209
261
|
|
|
210
|
-
adapter.approve_interaction(interaction_id)
|
|
262
|
+
adapter.approve_interaction(interaction_id, scope: scope)
|
|
211
263
|
end
|
|
212
264
|
|
|
213
265
|
# Reject a pending approval interaction by canonical id.
|
|
266
|
+
# @param feedback [String, nil] denial reason forwarded to the queue
|
|
214
267
|
# @return [Boolean]
|
|
215
|
-
def reject_interaction(session_id, interaction_id)
|
|
268
|
+
def reject_interaction(session_id, interaction_id, feedback: nil)
|
|
216
269
|
adapter = @store.get(session_id)
|
|
217
270
|
raise Ask::AppServer::SessionNotFound, "Session #{session_id} not found" unless adapter
|
|
218
271
|
|
|
219
|
-
adapter.reject_interaction(interaction_id)
|
|
272
|
+
adapter.reject_interaction(interaction_id, feedback: feedback)
|
|
220
273
|
end
|
|
221
274
|
|
|
222
275
|
# Approve every pending approval interaction.
|
|
276
|
+
# @param scope [Symbol, String] :once (default) or :session
|
|
223
277
|
# @return [Integer] number approved
|
|
224
|
-
def approve_all_interactions(session_id)
|
|
278
|
+
def approve_all_interactions(session_id, scope: :once)
|
|
225
279
|
adapter = @store.get(session_id)
|
|
226
280
|
raise Ask::AppServer::SessionNotFound, "Session #{session_id} not found" unless adapter
|
|
227
281
|
|
|
228
|
-
adapter.approve_all_interactions
|
|
282
|
+
adapter.approve_all_interactions(scope: scope)
|
|
229
283
|
end
|
|
230
284
|
|
|
231
285
|
# Reject every pending approval interaction.
|
|
286
|
+
# @param feedback [String, nil] denial reason forwarded to the queue
|
|
232
287
|
# @return [Integer] number rejected
|
|
233
|
-
def reject_all_interactions(session_id)
|
|
288
|
+
def reject_all_interactions(session_id, feedback: nil)
|
|
234
289
|
adapter = @store.get(session_id)
|
|
235
290
|
raise Ask::AppServer::SessionNotFound, "Session #{session_id} not found" unless adapter
|
|
236
291
|
|
|
237
|
-
adapter.reject_all_interactions
|
|
292
|
+
adapter.reject_all_interactions(feedback: feedback)
|
|
238
293
|
end
|
|
239
294
|
|
|
240
295
|
# ── Plan mode ───────────────────────────────────────────────────────
|
|
@@ -340,6 +395,20 @@ module Ask
|
|
|
340
395
|
nil
|
|
341
396
|
end
|
|
342
397
|
|
|
398
|
+
# Fail-closed workspace verification for durable resume: the
|
|
399
|
+
# canonical path when the caller's +path+ hashes to the stored
|
|
400
|
+
# +workspaceId+, nil otherwise (no path supplied, or a path for a
|
|
401
|
+
# different project). Never falls back to a stored raw path —
|
|
402
|
+
# there is none: only the hash is persisted.
|
|
403
|
+
def verified_resume_workspace(workspace_id, path)
|
|
404
|
+
return nil if workspace_id.nil? || path.nil?
|
|
405
|
+
|
|
406
|
+
canonical = canonical_workspace_path(path)
|
|
407
|
+
return nil unless canonical && workspace_identity(canonical) == workspace_id
|
|
408
|
+
|
|
409
|
+
canonical
|
|
410
|
+
end
|
|
411
|
+
|
|
343
412
|
# Resolve the configured model for a new agent session: parse an
|
|
344
413
|
# optional provider prefix and register it in the catalog so the
|
|
345
414
|
# bare id stays resolvable (see create_session).
|
|
@@ -30,13 +30,14 @@ module Ask
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
# Register a new session.
|
|
33
|
-
def add(session_id, adapter)
|
|
33
|
+
def add(session_id, adapter, workspace_id: nil)
|
|
34
34
|
model = adapter.respond_to?(:instance_variable_get) ? adapter.instance_variable_get(:@model) : nil
|
|
35
35
|
metadata = {
|
|
36
36
|
sessionId: session_id,
|
|
37
37
|
model: model,
|
|
38
38
|
createdAt: adapter.respond_to?(:created_at) ? adapter.created_at.iso8601 : Time.now.iso8601
|
|
39
39
|
}
|
|
40
|
+
metadata[:workspaceId] = workspace_id if workspace_id
|
|
40
41
|
|
|
41
42
|
@mutex.synchronize do
|
|
42
43
|
existing = @state.get("#{SESSION_PREFIX}#{session_id}")
|
|
@@ -50,6 +51,12 @@ module Ask
|
|
|
50
51
|
end
|
|
51
52
|
end
|
|
52
53
|
|
|
54
|
+
# Session metadata persisted independently of the live adapter.
|
|
55
|
+
def metadata(session_id)
|
|
56
|
+
value = @state.get("#{SESSION_PREFIX}#{session_id}")
|
|
57
|
+
value&.transform_keys(&:to_sym)
|
|
58
|
+
end
|
|
59
|
+
|
|
53
60
|
# Remove a session.
|
|
54
61
|
def remove(session_id)
|
|
55
62
|
@mutex.synchronize do
|
data/lib/ask-app-server.rb
CHANGED
|
@@ -14,6 +14,7 @@ require_relative "ask/app_server/connection"
|
|
|
14
14
|
require_relative "ask/app_server/agent_adapter"
|
|
15
15
|
require_relative "ask/app_server/permission_handler"
|
|
16
16
|
require_relative "ask/app_server/session_store"
|
|
17
|
+
require_relative "ask/app_server/project_permission_grants"
|
|
17
18
|
require_relative "ask/app_server/session_manager"
|
|
18
19
|
require_relative "ask/app_server/server"
|
|
19
20
|
require_relative "ask/app_server/socket_server"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ask-app-server
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -113,14 +113,14 @@ dependencies:
|
|
|
113
113
|
requirements:
|
|
114
114
|
- - ">="
|
|
115
115
|
- !ruby/object:Gem::Version
|
|
116
|
-
version: 0.
|
|
116
|
+
version: 0.2.0
|
|
117
117
|
type: :runtime
|
|
118
118
|
prerelease: false
|
|
119
119
|
version_requirements: !ruby/object:Gem::Requirement
|
|
120
120
|
requirements:
|
|
121
121
|
- - ">="
|
|
122
122
|
- !ruby/object:Gem::Version
|
|
123
|
-
version: 0.
|
|
123
|
+
version: 0.2.0
|
|
124
124
|
- !ruby/object:Gem::Dependency
|
|
125
125
|
name: minitest
|
|
126
126
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -214,6 +214,7 @@ files:
|
|
|
214
214
|
- lib/ask/app_server/event_translator.rb
|
|
215
215
|
- lib/ask/app_server/herdr_reporter.rb
|
|
216
216
|
- lib/ask/app_server/permission_handler.rb
|
|
217
|
+
- lib/ask/app_server/project_permission_grants.rb
|
|
217
218
|
- lib/ask/app_server/server.rb
|
|
218
219
|
- lib/ask/app_server/session_manager.rb
|
|
219
220
|
- lib/ask/app_server/session_store.rb
|