ask-app-server 0.4.29 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 59485b1b2235be854352ac85a2b3081eec346d03aa0c50584fdd68de1c800bb8
4
- data.tar.gz: 747df91a3b71de0957708fdb2e367dd79cbc5e1879e1fdfde250c8bfb7c77f5c
3
+ metadata.gz: '09c21d6313a0af92e78bed933837e2c292ad657deb958edc8e463e4cca52a4a1'
4
+ data.tar.gz: d6151661ac80585af0d25bed6363251b3a0a4db877308c50bd47d8260f628a2d
5
5
  SHA512:
6
- metadata.gz: 70e9914314b4baf1905949593466b6221925da1406637fb9c711ea76ed1748aa27d37a46716e0357b1295e8e2b3210b289b9fd52cf4048b3f7db31ad7e5a619f
7
- data.tar.gz: 2eca3687edcb45686bd8c7fe1ac74bdef04217ee76fe85a36944306c015abe311ccff1a240cdd9db7254e4d1502f442793fc380c7ac5cb820fcdca082b425d86
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
- apply_interaction(interaction_id) { |queue, id| queue.approve(id) }
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
- return { queue: queue } if @approval == :auto
489
+ opts = { queue: queue }
490
+ opts[:project_grants] = @project_grants if @project_grants
491
+ return opts if @approval == :auto
470
492
 
471
- { queue: queue, require_approval: @require_approval }
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
@@ -605,7 +644,7 @@ module Ask
605
644
  # queue (see Session#build_approval); this subclass only adds
606
645
  # observation hooks on top, using its own listeners so the session's
607
646
  # on_submit (pending-tool registration) is never clobbered.
608
- class EmittingApprovalQueue < Ask::Agent::ApprovalQueue
647
+ class EmittingApprovalQueue < Ask::Permissions::ApprovalQueue
609
648
  # @param on_submit [Proc, nil] called with the new {Action} after
610
649
  # submission (and after the auto-approval drain)
611
650
  # @param on_status [Proc, nil] called with an {Action} whose status
@@ -624,15 +663,15 @@ module Ask
624
663
 
625
664
  private
626
665
 
627
- def apply(action)
628
- result = super
629
- @on_status&.call(action.with(status: :approved))
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(action.with(status: :rejected))
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
@@ -95,23 +95,29 @@ module Ask
95
95
 
96
96
  # An approval action was queued: emit approval.required.
97
97
  #
98
- # @param action [Ask::Agent::ApprovalQueue::Action]
99
- def approval_required(action)
98
+ # @param action [Ask::Permissions::ApprovalQueue::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
 
107
108
  # An approval action changed status: emit approval.updated.
108
109
  #
109
- # @param action [Ask::Agent::ApprovalQueue::Action]
110
+ # @param action [Ask::Permissions::ApprovalQueue::Action]
110
111
  def approval_updated(action)
111
112
  status = action.status.to_s
112
113
  return unless %w[approved rejected].include?(status)
113
114
 
114
- emit("approval.updated", { "id" => "act_#{action.id}", "status" => status })
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 "securerandom"
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
- # When a blocked tool is called, this handler:
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
- @pending = {}
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, _context = {})
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
- request_approval(tool_call)
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
- entry = @pending[request_id]
69
- return false unless entry
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[:responded] = true
72
- entry[:approved] = (decision.to_s == "approve")
73
- entry[:reason] = reason
74
- entry[:condition].signal
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
- @pending.each_value do |entry|
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
- @pending.clear
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
- @mutex.synchronize { @pending.size }
140
+ @queue.pending_actions.size
95
141
  end
96
142
 
97
143
  # Are there any pending permission requests?
98
144
  def pending?
99
- pending_count > 0
145
+ @queue.any_pending?
100
146
  end
101
147
 
102
148
  private
103
149
 
104
- def request_approval(tool_call)
105
- request_id = SecureRandom.uuid
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
- @pending[request_id] = {
110
- tool_call: tool_call,
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 for #{tool_call.name} (#{request_id})")
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 = @pending[request_id]
133
- break unless entry # cancelled or already processed
134
- break if entry[:responded] # response received
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
- @pending.delete(request_id)
139
- @logger.debug("Permission request #{request_id} timed out")
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 = @pending.delete(request_id)
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 #{request_id}")
229
+ @logger.debug("Permission granted for #{action_id}")
155
230
  { action: :proceed }
156
231
  else
157
- @logger.debug("Permission denied for #{request_id}: #{response_reason}")
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
- @pending.delete(request_id) rescue nil
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(session_id)
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(nil),
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module AppServer
5
- VERSION = "0.4.29"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ask/agent"
4
+ require "ask/permissions"
4
5
  require "ask-session"
5
6
  require "ask-session-protocol"
6
7
  require "ask-tools-shell"
@@ -13,6 +14,7 @@ require_relative "ask/app_server/connection"
13
14
  require_relative "ask/app_server/agent_adapter"
14
15
  require_relative "ask/app_server/permission_handler"
15
16
  require_relative "ask/app_server/session_store"
17
+ require_relative "ask/app_server/project_permission_grants"
16
18
  require_relative "ask/app_server/session_manager"
17
19
  require_relative "ask/app_server/server"
18
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.29
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -107,6 +107,20 @@ dependencies:
107
107
  - - ">="
108
108
  - !ruby/object:Gem::Version
109
109
  version: 0.4.6
110
+ - !ruby/object:Gem::Dependency
111
+ name: ask-permissions
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: 0.2.0
117
+ type: :runtime
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: 0.2.0
110
124
  - !ruby/object:Gem::Dependency
111
125
  name: minitest
112
126
  requirement: !ruby/object:Gem::Requirement
@@ -200,6 +214,7 @@ files:
200
214
  - lib/ask/app_server/event_translator.rb
201
215
  - lib/ask/app_server/herdr_reporter.rb
202
216
  - lib/ask/app_server/permission_handler.rb
217
+ - lib/ask/app_server/project_permission_grants.rb
203
218
  - lib/ask/app_server/server.rb
204
219
  - lib/ask/app_server/session_manager.rb
205
220
  - lib/ask/app_server/session_store.rb